@inetafrica/open-claudia 3.0.26 → 3.0.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 +9 -0
- package/bin/agency-ledger.js +91 -0
- package/bin/cli.js +15 -0
- package/bin/prompt-budget.js +49 -0
- package/bin/recall.js +6 -0
- package/channels/telegram/adapter.js +26 -3
- package/core/actions.js +25 -0
- package/core/agency/deliberate.js +332 -0
- package/core/agency/ledger.js +294 -0
- package/core/connected-apps.js +15 -0
- package/core/guardrail.js +251 -0
- package/core/handlers.js +41 -1
- package/core/prompt-budget.js +165 -0
- package/core/runner.js +19 -1
- package/core/state.js +16 -0
- package/core/system-prompt.js +29 -0
- package/package.json +6 -2
- package/test-agency-deliberate.js +119 -0
- package/test-agency-ledger.js +114 -0
- package/test-guardrail.js +126 -0
- package/test-prompt-budget.js +78 -0
- package/test-provider-language.js +12 -10
- package/test-telegram-poll-recovery.js +81 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Track C — dynamic owner-guardrail capture. Deterministic detection tiers,
|
|
2
|
+
// the hard owner gate, C0 detect+confirm (Y/N pending), C1 auto-capture with
|
|
3
|
+
// undo, and dedupe against existing lessons. No network, redirected config dir.
|
|
4
|
+
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "oc-guard-"));
|
|
11
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = TMP;
|
|
12
|
+
process.env.GUARDRAIL_CAPTURE = "on";
|
|
13
|
+
|
|
14
|
+
const G = require("./core/guardrail");
|
|
15
|
+
const lessons = require("./core/lessons");
|
|
16
|
+
|
|
17
|
+
const OWNER = { isOwner: true, channelId: "telegram:1" };
|
|
18
|
+
const EXTERNAL = { isOwner: false, channelId: "telegram:9" };
|
|
19
|
+
|
|
20
|
+
function resetLessons() {
|
|
21
|
+
try { fs.rmSync(lessons.LESSONS_FILE, { force: true }); } catch (e) {}
|
|
22
|
+
try { fs.rmSync(lessons.LESSONS_META_FILE, { force: true }); } catch (e) {}
|
|
23
|
+
try { fs.rmSync(G.PENDING_FILE, { force: true }); } catch (e) {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── detection tiers ─────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
// high: explicit durable marker → clean imperative, marker stripped
|
|
29
|
+
let d = G.detect("From now on, always send the deep link after posting a Spaces comment.");
|
|
30
|
+
assert.strictEqual(d.isRule, true);
|
|
31
|
+
assert.strictEqual(d.confidence, "high");
|
|
32
|
+
assert.strictEqual(d.trigger, "marker");
|
|
33
|
+
assert.ok(/send the deep link/i.test(d.rule));
|
|
34
|
+
assert.ok(!/from now on/i.test(d.rule), "leading marker stripped");
|
|
35
|
+
|
|
36
|
+
// high: bare negative directive (never / stop-gerund)
|
|
37
|
+
assert.strictEqual(G.detect("Never send voice notes by default.").confidence, "high");
|
|
38
|
+
assert.strictEqual(G.detect("Stop summarizing every response.").confidence, "high");
|
|
39
|
+
assert.strictEqual(G.detect("Don't ever deploy on Fridays.").confidence, "high");
|
|
40
|
+
|
|
41
|
+
// medium: softer imperative → ask first
|
|
42
|
+
const dm = G.detect("Always double-check the staging config before a deploy.");
|
|
43
|
+
assert.strictEqual(dm.confidence, "medium");
|
|
44
|
+
assert.strictEqual(G.detect("Please don't touch prod without asking me first.").confidence, "medium");
|
|
45
|
+
|
|
46
|
+
// silence: questions, past-tense "never", one-off "stop <noun>", junk
|
|
47
|
+
assert.strictEqual(G.detect("What colour are the fish?").isRule, false);
|
|
48
|
+
assert.strictEqual(G.detect("I never got the email from Emmanuel.").isRule, false);
|
|
49
|
+
assert.strictEqual(G.detect("Stop the server.").isRule, false, "stop + non-gerund is a one-off action");
|
|
50
|
+
assert.strictEqual(G.detect("Never mind.").isRule, false, "below the word floor");
|
|
51
|
+
assert.strictEqual(G.detect("Make it a rule?").isRule, false, "a question is not a directive");
|
|
52
|
+
|
|
53
|
+
// ── owner gate (hard) ───────────────────────────────────────────────
|
|
54
|
+
(async () => {
|
|
55
|
+
resetLessons();
|
|
56
|
+
const ext = await G.consider({ text: "From now on always run the linter first.", speaker: EXTERNAL, announce: null });
|
|
57
|
+
assert.strictEqual(ext.action, "external-skip", "external speaker never captures");
|
|
58
|
+
assert.strictEqual(lessons.readLessons().length, 0, "nothing written for an external");
|
|
59
|
+
|
|
60
|
+
// ── C1: high-confidence auto-capture + announce with Undo button ──
|
|
61
|
+
resetLessons();
|
|
62
|
+
const calls = [];
|
|
63
|
+
const announce = (text, opts) => { calls.push({ text, opts }); };
|
|
64
|
+
const hi = await G.consider({ text: "From now on, always send the deep link after posting a comment.", speaker: OWNER, announce });
|
|
65
|
+
assert.strictEqual(hi.action, "captured");
|
|
66
|
+
assert.strictEqual(calls.length, 1, "announced once");
|
|
67
|
+
assert.ok(calls[0].text.includes("standing rule"));
|
|
68
|
+
const undoBtn = calls[0].opts.keyboard.inline_keyboard[0][0];
|
|
69
|
+
assert.strictEqual(undoBtn.callback_data, `grd:undo:${hi.id}`, "undo button carries the lesson id");
|
|
70
|
+
const stored = lessons.readLessons();
|
|
71
|
+
assert.strictEqual(stored.length, 1, "one lesson written");
|
|
72
|
+
assert.ok(/send the deep link/i.test(stored[0].text));
|
|
73
|
+
// provenance: origin recorded as guardrail
|
|
74
|
+
assert.strictEqual((lessons.readMeta()[hi.id] || {}).origin, "guardrail");
|
|
75
|
+
|
|
76
|
+
// one-tap undo removes it
|
|
77
|
+
const un = G.undo(hi.id);
|
|
78
|
+
assert.strictEqual(un.ok, true);
|
|
79
|
+
assert.strictEqual(lessons.readLessons().length, 0, "undo removed the rule");
|
|
80
|
+
|
|
81
|
+
// ── dedupe: same rule twice reinforces, never duplicates ──
|
|
82
|
+
resetLessons();
|
|
83
|
+
const a = await G.consider({ text: "Never commit the .env file.", speaker: OWNER, announce: null });
|
|
84
|
+
assert.strictEqual(a.action, "captured");
|
|
85
|
+
const b = await G.consider({ text: "Never commit the .env file.", speaker: OWNER, announce: null });
|
|
86
|
+
assert.strictEqual(b.action, "reinforced", "second identical rule reinforces");
|
|
87
|
+
assert.strictEqual(lessons.readLessons().length, 1, "no duplicate lesson");
|
|
88
|
+
|
|
89
|
+
// ── C0: medium-confidence asks first (pending), then confirm captures ──
|
|
90
|
+
resetLessons();
|
|
91
|
+
const calls2 = [];
|
|
92
|
+
const announce2 = (text, opts) => { calls2.push({ text, opts }); };
|
|
93
|
+
const med = await G.consider({ text: "Always run the full test suite before pushing.", speaker: OWNER, announce: announce2 });
|
|
94
|
+
assert.strictEqual(med.action, "asked");
|
|
95
|
+
assert.ok(med.token, "a pending token is issued");
|
|
96
|
+
assert.strictEqual(lessons.readLessons().length, 0, "nothing captured until confirmed");
|
|
97
|
+
const kb = calls2[0].opts.keyboard.inline_keyboard[0];
|
|
98
|
+
assert.strictEqual(kb[0].callback_data, `grd:save:${med.token}`);
|
|
99
|
+
assert.strictEqual(kb[1].callback_data, `grd:no:${med.token}`);
|
|
100
|
+
|
|
101
|
+
// confirm → capture; token is one-shot (second confirm expired)
|
|
102
|
+
const ok = G.confirmPending(med.token);
|
|
103
|
+
assert.strictEqual(ok.ok, true);
|
|
104
|
+
assert.strictEqual(ok.added, true);
|
|
105
|
+
assert.strictEqual(lessons.readLessons().length, 1, "confirm captured the rule");
|
|
106
|
+
assert.strictEqual(G.confirmPending(med.token).ok, false, "token is single-use");
|
|
107
|
+
|
|
108
|
+
// drop → discards the pending without capturing
|
|
109
|
+
resetLessons();
|
|
110
|
+
const med2 = await G.consider({ text: "Prefer GitOps over direct kubectl for cluster changes.", speaker: OWNER, announce: null });
|
|
111
|
+
assert.strictEqual(med2.action, "asked");
|
|
112
|
+
assert.strictEqual(G.dropPending(med2.token).ok, true);
|
|
113
|
+
assert.strictEqual(lessons.readLessons().length, 0, "drop captured nothing");
|
|
114
|
+
assert.strictEqual(G.confirmPending(med2.token).ok, false, "dropped token is gone");
|
|
115
|
+
|
|
116
|
+
// ── enabled gate ──
|
|
117
|
+
resetLessons();
|
|
118
|
+
process.env.GUARDRAIL_CAPTURE = "off";
|
|
119
|
+
const off = await G.consider({ text: "From now on always foo the bar.", speaker: OWNER, announce: null });
|
|
120
|
+
assert.strictEqual(off.action, "disabled");
|
|
121
|
+
assert.strictEqual(lessons.readLessons().length, 0, "disabled captures nothing");
|
|
122
|
+
process.env.GUARDRAIL_CAPTURE = "on";
|
|
123
|
+
|
|
124
|
+
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}
|
|
125
|
+
console.log("test-guardrail: OK");
|
|
126
|
+
})();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Track X0 — always-on prompt budget instrumentation.
|
|
2
|
+
// Verifies the measure/record/summary contract without touching the real
|
|
3
|
+
// config dir (redirected to a temp OPEN_CLAUDIA_CONFIG_DIR before require).
|
|
4
|
+
|
|
5
|
+
const assert = require("assert");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "oc-budget-"));
|
|
11
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = TMP;
|
|
12
|
+
process.env.PROMPT_BUDGET = "on";
|
|
13
|
+
|
|
14
|
+
const budget = require("./core/prompt-budget");
|
|
15
|
+
budget._resetForTest();
|
|
16
|
+
|
|
17
|
+
// --- estimateTokens: ~chars/4, never negative, tolerant of non-strings ---
|
|
18
|
+
assert.strictEqual(budget.estimateTokens(""), 0);
|
|
19
|
+
assert.strictEqual(budget.estimateTokens("abcd"), 1);
|
|
20
|
+
assert.strictEqual(budget.estimateTokens("abcde"), 2); // ceil(5/4)
|
|
21
|
+
assert.strictEqual(budget.estimateTokens(null), 0);
|
|
22
|
+
assert.strictEqual(budget.estimateTokens(undefined), 0);
|
|
23
|
+
|
|
24
|
+
// --- measure: always-on vs recall vs userPrompt split ---
|
|
25
|
+
const m = budget.measure({
|
|
26
|
+
core: "x".repeat(400), // 100 tok, always-on
|
|
27
|
+
runtime: "y".repeat(40), // 10 tok, always-on
|
|
28
|
+
speaker: "z".repeat(40), // 10 tok, always-on
|
|
29
|
+
recall: "r".repeat(200), // 50 tok, VARIABLE
|
|
30
|
+
transcript: "t".repeat(40), // 10 tok, VARIABLE
|
|
31
|
+
userPrompt: "u".repeat(20), // 5 tok, user
|
|
32
|
+
});
|
|
33
|
+
assert.strictEqual(m.alwaysOn.tokens, 120, "always-on = core+runtime+speaker");
|
|
34
|
+
assert.strictEqual(m.recall.tokens, 50, "recall isolated");
|
|
35
|
+
assert.strictEqual(m.variable.tokens, 60, "variable = recall + transcript");
|
|
36
|
+
assert.strictEqual(m.userPrompt.tokens, 5);
|
|
37
|
+
assert.strictEqual(m.total.tokens, 120 + 60 + 5, "total = alwaysOn + variable + userPrompt");
|
|
38
|
+
assert.strictEqual(m.components.core.tokens, 100);
|
|
39
|
+
|
|
40
|
+
// recall must NOT be counted in the always-on floor
|
|
41
|
+
const m2 = budget.measure({ core: "a".repeat(4), recall: "b".repeat(4000) });
|
|
42
|
+
assert.strictEqual(m2.alwaysOn.tokens, 1, "recall excluded from always-on floor");
|
|
43
|
+
assert.strictEqual(m2.recall.tokens, 1000);
|
|
44
|
+
|
|
45
|
+
// --- record + summary: rolling averages, max, latest snapshot ---
|
|
46
|
+
budget.record(budget.measure({ core: "x".repeat(400) }), { provider: "claude", runId: "r1" }); // 100
|
|
47
|
+
budget.record(budget.measure({ core: "x".repeat(800) }), { provider: "claude", runId: "r2" }); // 200
|
|
48
|
+
const s = budget.summary();
|
|
49
|
+
assert.strictEqual(s.turns, 2);
|
|
50
|
+
assert.strictEqual(s.avgAlwaysOnTokens, 150, "avg of 100 & 200");
|
|
51
|
+
assert.strictEqual(s.maxAlwaysOnTokens, 200);
|
|
52
|
+
assert.ok(s.latest && s.latest.alwaysOnTokens === 200, "latest snapshot is the last record");
|
|
53
|
+
assert.strictEqual(s.componentAvgTokens.core, 150);
|
|
54
|
+
|
|
55
|
+
// jsonl written with one line per record
|
|
56
|
+
assert.ok(fs.existsSync(budget.LOG_FILE), "jsonl log written");
|
|
57
|
+
const lines = fs.readFileSync(budget.LOG_FILE, "utf8").trim().split("\n");
|
|
58
|
+
assert.strictEqual(lines.length, 2);
|
|
59
|
+
const first = JSON.parse(lines[0]);
|
|
60
|
+
assert.strictEqual(first.alwaysOnTokens, 100);
|
|
61
|
+
assert.strictEqual(first.provider, "claude");
|
|
62
|
+
|
|
63
|
+
// --- record is best-effort: junk input never throws, never records ---
|
|
64
|
+
const before = budget.summary().turns;
|
|
65
|
+
budget.record(null);
|
|
66
|
+
budget.record({});
|
|
67
|
+
budget.record({ total: null });
|
|
68
|
+
assert.strictEqual(budget.summary().turns, before, "invalid measurements are ignored");
|
|
69
|
+
|
|
70
|
+
// --- disabled gate ---
|
|
71
|
+
budget._resetForTest();
|
|
72
|
+
process.env.PROMPT_BUDGET = "off";
|
|
73
|
+
budget.record(budget.measure({ core: "x".repeat(400) }), { provider: "claude" });
|
|
74
|
+
assert.strictEqual(budget.summary().turns, 0, "PROMPT_BUDGET=off disables recording");
|
|
75
|
+
process.env.PROMPT_BUDGET = "on";
|
|
76
|
+
|
|
77
|
+
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}
|
|
78
|
+
console.log("test-prompt-budget: OK");
|
|
@@ -32,16 +32,18 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
|
|
|
32
32
|
|
|
33
33
|
const pkg = JSON.parse(read("package.json"));
|
|
34
34
|
assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
35
|
-
// 3.0.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
35
|
+
// 3.0.27 approved by Sumeet 2026-07-15 ("work through till it's done and push it
|
|
36
|
+
// out"): the non-gated architecture batch. (1) X0 — always-on prompt-budget
|
|
37
|
+
// instrumentation: measure the fixed floor vs per-turn recall payload, telemetry
|
|
38
|
+
// only (no prompt change, no trim). (2) B0 — /agenda read-only work ledger
|
|
39
|
+
// (tasks + Spaces + notifications). (3) B1 — /next advisory deliberation that
|
|
40
|
+
// proposes a pick and acts on nothing. (4) C0/C1 — owner-guardrail capture:
|
|
41
|
+
// "from now on / never / stop" statements become always-loaded lessons (asked or
|
|
42
|
+
// auto-captured with announce + one-tap undo), owner-only, never from externals.
|
|
43
|
+
// (5) Turn-aware reboot: the Telegram 10-min poll-wedge backstop defers its
|
|
44
|
+
// self-exit while any turn is running/queued so a restart never interrupts
|
|
45
|
+
// in-flight work. B2+ autonomy and Track A stay gated.
|
|
46
|
+
assert.strictEqual(pkg.version, "3.0.27", "release version must remain unchanged without explicit approval");
|
|
45
47
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
46
48
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
47
49
|
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const assert = require("assert");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
// Redirect config before loading the adapter so the idle wedge-exit test's
|
|
9
|
+
// recordExitReason writes to a throwaway dir, never the real
|
|
10
|
+
// ~/.open-claudia/last-exit.json (which would make the next real boot report a
|
|
11
|
+
// phantom "polling wedged" restart reason).
|
|
12
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "oc-poll-"));
|
|
13
|
+
|
|
4
14
|
const { TelegramAdapter } = require("./channels/telegram/adapter");
|
|
5
15
|
|
|
6
16
|
async function testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog() {
|
|
@@ -165,11 +175,82 @@ async function test409ConflictSeversGhostSocketAndRestartsIdle() {
|
|
|
165
175
|
}
|
|
166
176
|
}
|
|
167
177
|
|
|
178
|
+
// The 10-min wedge backstop must NEVER reboot mid-turn: a restart there kills
|
|
179
|
+
// in-flight work and breaks the seamless resume. While a turn is active the heal
|
|
180
|
+
// defers the exit and falls through to in-place recovery (stop/destroy/start)
|
|
181
|
+
// instead — which may itself clear the wedge with no restart at all.
|
|
182
|
+
async function testWedgeExitDefersWhileTurnInFlight() {
|
|
183
|
+
const calls = [];
|
|
184
|
+
let exited = false;
|
|
185
|
+
const adapter = Object.create(TelegramAdapter.prototype);
|
|
186
|
+
adapter._healTimer = null;
|
|
187
|
+
adapter._healthyTimer = null;
|
|
188
|
+
adapter._conflictSince = 0;
|
|
189
|
+
adapter._wedgedSince = Date.now() - 11 * 60 * 1000; // wedged >10min
|
|
190
|
+
adapter._deferredExitLogAt = 0;
|
|
191
|
+
adapter._healBackoff = 0;
|
|
192
|
+
adapter._anyTurnActive = () => true; // a turn is running/queued
|
|
193
|
+
adapter._agent = { destroy() { calls.push("destroy"); } };
|
|
194
|
+
adapter.bot = {
|
|
195
|
+
async stopPolling() { calls.push("stop"); },
|
|
196
|
+
async startPolling() { calls.push("start"); },
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const realExit = process.exit;
|
|
200
|
+
process.exit = () => { exited = true; throw new Error("process.exit must not be called mid-turn"); };
|
|
201
|
+
const realSetTimeout = global.setTimeout;
|
|
202
|
+
const realClearTimeout = global.clearTimeout;
|
|
203
|
+
global.setTimeout = (fn, ms) => { if (ms === 500) fn(); return { ms }; };
|
|
204
|
+
global.clearTimeout = () => {};
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await adapter._heal();
|
|
208
|
+
} finally {
|
|
209
|
+
process.exit = realExit;
|
|
210
|
+
global.setTimeout = realSetTimeout;
|
|
211
|
+
global.clearTimeout = realClearTimeout;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
assert.strictEqual(exited, false, "a wedge backstop must NOT exit while a turn is in flight");
|
|
215
|
+
assert.deepStrictEqual(calls, ["stop", "destroy", "start"],
|
|
216
|
+
"a deferred wedge must still attempt in-place recovery instead of rebooting");
|
|
217
|
+
assert.ok(adapter._deferredExitLogAt > 0, "the deferral must be logged (throttle stamp set)");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Once the process is idle (no turn running/queued), the same backstop exits for
|
|
221
|
+
// a clean restart exactly as before — the deferral only postpones, never cancels.
|
|
222
|
+
async function testWedgeExitFiresWhenIdle() {
|
|
223
|
+
let exitCode;
|
|
224
|
+
const adapter = Object.create(TelegramAdapter.prototype);
|
|
225
|
+
adapter._healTimer = null;
|
|
226
|
+
adapter._conflictSince = 0;
|
|
227
|
+
adapter._wedgedSince = Date.now() - 11 * 60 * 1000; // wedged >10min
|
|
228
|
+
adapter._deferredExitLogAt = 0;
|
|
229
|
+
adapter._anyTurnActive = () => false; // idle
|
|
230
|
+
adapter._agent = { destroy() {} };
|
|
231
|
+
adapter.bot = { async stopPolling() {}, async startPolling() {} };
|
|
232
|
+
|
|
233
|
+
const realExit = process.exit;
|
|
234
|
+
process.exit = (code) => { exitCode = code; throw new Error("__exit__"); };
|
|
235
|
+
try {
|
|
236
|
+
await adapter._heal();
|
|
237
|
+
assert.fail("the idle wedge backstop must exit");
|
|
238
|
+
} catch (e) {
|
|
239
|
+
if (e.message !== "__exit__") throw e;
|
|
240
|
+
} finally {
|
|
241
|
+
process.exit = realExit;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
assert.strictEqual(exitCode, 1, "the idle wedge backstop must exit(1) for a clean restart");
|
|
245
|
+
}
|
|
246
|
+
|
|
168
247
|
(async () => {
|
|
169
248
|
await testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog();
|
|
170
249
|
await testHealStopsPlainlyDestroysAgentAndRestartsIdle();
|
|
171
250
|
await testStopPollingCleanDoesNotHangOnAWedgedStop();
|
|
172
251
|
await test409ConflictSeversGhostSocketAndRestartsIdle();
|
|
252
|
+
await testWedgeExitDefersWhileTurnInFlight();
|
|
253
|
+
await testWedgeExitFiresWhenIdle();
|
|
173
254
|
console.log("telegram poll recovery OK");
|
|
174
255
|
})().catch((err) => {
|
|
175
256
|
console.error(err.stack || err);
|