alvin-bot 4.12.2 โ†’ 4.12.3

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
@@ -2,6 +2,70 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.12.3] โ€” 2026-04-15
6
+
7
+ ### ๐Ÿ› Patch: Background sub-agent no longer blocks the main Telegram session
8
+
9
+ **The bug Ali reported:** After launching an async sub-agent (`run_in_background: true`), sending any follow-up message to the bot silently stalled for 2+ minutes before being processed. v4.12.1/v4.12.2 attempted a prompt-hint mitigation but did NOT address the architectural root cause.
10
+
11
+ **Root cause (re-diagnosed with live SDK event logs):** The Claude Agent SDK's CLI subprocess stays alive for the full duration of a background task so it can inject the `<task-notification>` inline into the NEXT assistant turn. While that subprocess idles, Alvin's query iterator is still being drained, `session.isProcessing` stays `true`, and every new user message gets pushed into the 3-slot queue โ€” which doesn't auto-drain. From the user's perspective: send "A" โ†’ nothing happens for 2 minutes.
12
+
13
+ **The fix (architectural workaround):** New session field `pendingBackgroundCount` tracks the number of background agents currently in-flight. When a new message arrives while `isProcessing=true` AND the counter is `>0`, the handler:
14
+
15
+ 1. **Aborts the blocked query** instead of queueing. The old SDK subprocess dies; the background task's own detached subprocess keeps writing to its `output_file`.
16
+ 2. **Starts a fresh SDK session** (`resume: null`) for the new message so it doesn't inherit the block. Recent conversation history is carried forward via the bridge preamble so Claude retains context.
17
+ 3. **Relies on the existing `async-agent-watcher` (v4.10.0)** to poll the background task's `output_file` and deliver the result as a separate Telegram message via `subagent-delivery.ts`. The watcher decrements the counter when it delivers, so subsequent messages go back to normal SDK-resume behavior.
18
+
19
+ **Net effect:** Sending "A" during a 5-minute research task now gets processed in ~200ms instead of after 5 minutes. The background research still delivers its result via a separate message when ready.
20
+
21
+ ### Technical details
22
+
23
+ **New module** `src/handlers/background-bypass.ts` โ€” pure state-machine helpers:
24
+ - `shouldBypassQueue(state)` โ€” returns true when `isProcessing=true`, `pendingBackgroundCount>0`, and an unaborted `abortController` exists
25
+ - `shouldBypassSdkResume(state)` โ€” returns true when `pendingBackgroundCount>0`, signalling the next query should pass `sessionId=null`
26
+ - `waitUntilProcessingFalse(session, timeoutMs, tickMs)` โ€” poll-waits for the old handler's `finally` block to flip the flag before the new query starts
27
+
28
+ **`src/services/session.ts`** โ€” new field `pendingBackgroundCount: number` (default 0, reset on `/new`). Not persisted across restarts โ€” the watcher re-hydrates its own state file and delivery still works, and starting a fresh counter after restart avoids stale drift.
29
+
30
+ **`src/services/async-agent-watcher.ts`** โ€” `PendingAsyncAgent` gets an optional `sessionKey` field. On every delivery path (completed/failed/timeout), a new `decrementPendingCount(sessionKey)` helper clamps the counter at 0 using `Math.max`. Missing/unknown session keys are a no-op (backwards compatible with pre-v4.12.3 persisted state files).
31
+
32
+ **`src/handlers/async-agent-chunk-handler.ts`** โ€” `TurnContext` gets `sessionKey`. When `registerPendingAgent` is called, the counter is incremented in the same function.
33
+
34
+ **`src/handlers/message.ts`** (Telegram):
35
+ - Computes `sessionKey` once at the top of the handler and passes it everywhere
36
+ - `if (session.isProcessing)` branch now checks `shouldBypassQueue` first โ€” if true, aborts + waits for cleanup + falls through to process the new message. If false, queues as before.
37
+ - When queueing, the handler now sends a text reply (`"โณ Eine Anfrage lรคuft gerade. Deine Nachricht ist in der Warteschlange..."`) in addition to the ๐Ÿ“ reaction, so the user sees what happened (reactions alone were too subtle)
38
+ - New `bypassResume` variable controls whether `queryOpts.sessionId` is `null` (fresh session) or `session.sessionId` (normal resume)
39
+ - Bridge preamble now has two modes: the existing "SDK recovery" mode that bridges fallback turns, plus a new "bypass" mode that bridges the last 10 turns when starting a fresh session mid-conversation
40
+ - New `_bypassAbortFired` session flag + `bypassAborted` local flag ensure that the old handler silently absorbs the abort error instead of showing a confusing "request cancelled" reply, and the fresh handler's finalize/broadcast/๐Ÿ‘ reaction path is skipped for the aborted turn
41
+
42
+ ### Known limitations
43
+
44
+ - **Platform coverage**: bypass path is Telegram-only in v4.12.3. Slack/Discord/WhatsApp handlers (`src/handlers/platform-message.ts`) don't currently handle `tool_result` chunks at all, so async agents can't be registered on those platforms. That's a pre-existing limitation that will be fixed in a future release.
45
+ - **SDK behavior dependency**: the fix assumes the background task's own subprocess is detached from the parent SDK query's `AbortController`. Empirically this holds (the watcher delivers results even after bypass-abort), but if a future SDK release changes this we'd need to either stop using `run_in_background` and rely on a pure Alvin-side background dispatch (bigger change) or add a targeted `process.kill` for the parent only, keeping the child alive.
46
+ - **Restart mid-flight**: if the bot restarts while a background agent is pending, the session's counter starts at 0 on restart. The watcher re-hydrates its own state file and still delivers the result correctly, but the session's "is this blocked?" signal is lost, so the first post-restart message might use SDK resume on the old (possibly-blocked) session ID. Minor cosmetic issue, not a data loss.
47
+
48
+ ### Testing
49
+
50
+ - **Baseline**: 396 tests (v4.12.2)
51
+ - **New tests**: +40
52
+ - `test/session-pending-background.test.ts` โ€” 4 tests (counter wiring, reset, clamp)
53
+ - `test/watcher-pending-count.test.ts` โ€” 6 tests (decrement on delivery/timeout/failure, missing sessionKey, multi-agent)
54
+ - `test/async-agent-chunk-flow.test.ts` โ€” +3 tests (sessionKey propagation, counter stacking, non-async no-op)
55
+ - `test/background-bypass.test.ts` โ€” 12 tests (pure helpers: shouldBypassQueue, shouldBypassSdkResume, waitUntilProcessingFalse)
56
+ - `test/background-bypass-integration.test.ts` โ€” 6 tests (full lifecycle, stress, session isolation)
57
+ - `test/background-bypass-stress.test.ts` โ€” 9 tests (100 parallel sessions, 200 churn cycles, extreme drift, /new during pending, ephemeral session, mixed rollout, timing edge cases, high load 50ร—4 agents)
58
+ - **Total**: 436 tests, all green, TSC clean
59
+
60
+ ### Files changed
61
+
62
+ - **NEW**: `src/handlers/background-bypass.ts`
63
+ - **NEW tests**: `test/session-pending-background.test.ts`, `test/watcher-pending-count.test.ts`, `test/background-bypass.test.ts`, `test/background-bypass-integration.test.ts`, `test/background-bypass-stress.test.ts`
64
+ - **Modified**: `src/handlers/message.ts` (bypass wiring + visible queue reply), `src/handlers/async-agent-chunk-handler.ts` (sessionKey + counter increment), `src/services/async-agent-watcher.ts` (sessionKey in PendingAsyncAgent + decrement on delivery), `src/services/session.ts` (pendingBackgroundCount field + _bypassAbortFired flag), `src/services/session-persistence.ts` (counter not persisted โ€” reset on restart), `test/async-agent-chunk-flow.test.ts` (new assertions)
65
+ - **Version**: `package.json` 4.12.2 โ†’ 4.12.3
66
+
67
+ ---
68
+
5
69
  ## [4.12.2] โ€” 2026-04-15
6
70
 
7
71
  ### ๐Ÿ”’ Security patch: file permissions, ALLOWED_USERS hard-fail, exec-guard hardening, CVE updates
@@ -1,5 +1,6 @@
1
1
  import { parseAsyncLaunchedToolResult } from "../services/async-agent-parser.js";
2
2
  import { registerPendingAgent } from "../services/async-agent-watcher.js";
3
+ import { getAllSessions } from "../services/session.js";
3
4
  /**
4
5
  * Inspect a stream chunk; if it's an Agent async_launched tool_result,
5
6
  * register the pending agent with the watcher.
@@ -29,5 +30,21 @@ export function handleToolResultChunk(chunk, ctx) {
29
30
  chatId: ctx.chatId,
30
31
  userId: ctx.userId,
31
32
  toolUseId: chunk.toolUseId ?? null,
33
+ sessionKey: ctx.sessionKey,
32
34
  });
35
+ // v4.12.3 โ€” Increment the session's pendingBackgroundCount so the
36
+ // main handler knows a background task is tying up the SDK's CLI
37
+ // subprocess. The watcher decrements this when it delivers the result.
38
+ // Guarded: missing sessionKey or unknown session is a no-op.
39
+ if (ctx.sessionKey) {
40
+ try {
41
+ const s = getAllSessions().get(ctx.sessionKey);
42
+ if (s) {
43
+ s.pendingBackgroundCount = (s.pendingBackgroundCount ?? 0) + 1;
44
+ }
45
+ }
46
+ catch {
47
+ /* never let counter updates break registration */
48
+ }
49
+ }
33
50
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * v4.12.3 โ€” Background-agent bypass helpers.
3
+ *
4
+ * Pure state-machine helpers used by the Telegram + platform message
5
+ * handlers to decide whether to:
6
+ * 1. Abort a running query instead of queueing the next user message,
7
+ * when the running query is blocked waiting for a background
8
+ * task-notification (SDK's CLI subprocess stays alive for the full
9
+ * duration of the background task).
10
+ * 2. Start the next SDK query with a fresh session (sessionId=null)
11
+ * when any background agent is still pending, so the new query
12
+ * doesn't inherit the old session's block.
13
+ *
14
+ * These are separated into their own module so they can be unit tested
15
+ * without a grammy Context mock.
16
+ */
17
+ /**
18
+ * Decide whether to bypass the normal "queue this message" branch and
19
+ * interrupt the running query so the new message can proceed immediately.
20
+ *
21
+ * True when:
22
+ * - A query is currently running (`isProcessing`)
23
+ * - At least one background agent is pending in this session
24
+ * - An unaborted abortController exists to cancel the running query
25
+ *
26
+ * Otherwise false โ†’ fall back to the normal queue/drop behavior.
27
+ */
28
+ export function shouldBypassQueue(state) {
29
+ if (!state.isProcessing)
30
+ return false;
31
+ if (state.pendingBackgroundCount <= 0)
32
+ return false;
33
+ const ac = state.abortController;
34
+ if (!ac)
35
+ return false;
36
+ if (ac.signal.aborted)
37
+ return false;
38
+ return true;
39
+ }
40
+ /**
41
+ * Decide whether the next SDK query should skip `resume: sessionId`
42
+ * and start a fresh session instead. Needed when a background agent is
43
+ * still pending โ€” resuming the original session would inherit its block
44
+ * (the SDK's CLI subprocess for that session is waiting to deliver the
45
+ * task-notification inline). A fresh session has no such block and
46
+ * proceeds immediately. Context is preserved via the bridge preamble
47
+ * (buildBridgeMessage in message.ts).
48
+ */
49
+ export function shouldBypassSdkResume(state) {
50
+ return state.pendingBackgroundCount > 0;
51
+ }
52
+ /**
53
+ * Poll-wait until `session.isProcessing` becomes false (or the timeout
54
+ * elapses). Returns true if the flag flipped, false on timeout.
55
+ *
56
+ * Used by the bypass path: after calling `abort()` on the running query,
57
+ * we wait for its finally block to run and flip isProcessing=false
58
+ * before starting the new query. The handler's own message loop is the
59
+ * one flipping the flag, so we just have to yield the event loop and
60
+ * re-check.
61
+ *
62
+ * Timeouts above 0 are recommended. Default tick interval is 50ms which
63
+ * is short enough that the fall-through feels instant to the user.
64
+ */
65
+ export async function waitUntilProcessingFalse(session, timeoutMs, tickMs = 50) {
66
+ if (!session.isProcessing)
67
+ return true;
68
+ const start = Date.now();
69
+ while (session.isProcessing) {
70
+ if (Date.now() - start >= timeoutMs)
71
+ return false;
72
+ await new Promise((resolve) => setTimeout(resolve, tickMs));
73
+ }
74
+ return true;
75
+ }
@@ -18,6 +18,7 @@ import { t } from "../i18n.js";
18
18
  import { isHarmlessTelegramError } from "../util/telegram-error-filter.js";
19
19
  import { handleToolResultChunk } from "./async-agent-chunk-handler.js";
20
20
  import { createStuckTimer } from "./stuck-timer.js";
21
+ import { shouldBypassQueue, shouldBypassSdkResume, waitUntilProcessingFalse, } from "./background-bypass.js";
21
22
  /**
22
23
  * Stuck-only timeout โ€” NO absolute cap.
23
24
  *
@@ -152,7 +153,8 @@ export async function handleMessage(ctx) {
152
153
  text = `[Replying to previous message: "${quotedText}"]\n\n${text}`;
153
154
  }
154
155
  const userId = ctx.from.id;
155
- const session = getSession(buildSessionKey("telegram", ctx.chat.id, userId));
156
+ const sessionKey = buildSessionKey("telegram", ctx.chat.id, userId);
157
+ const session = getSession(sessionKey);
156
158
  // Track user profile
157
159
  touchProfile(userId, ctx.from?.first_name, ctx.from?.username, "telegram", text);
158
160
  // Sync session language from persistent profile (on first message)
@@ -163,15 +165,54 @@ export async function handleMessage(ctx) {
163
165
  session.language = profile.language;
164
166
  }
165
167
  if (session.isProcessing) {
166
- // Queue the message instead of rejecting it (max 3)
167
- if (session.messageQueue.length < 3) {
168
- session.messageQueue.push(text);
169
- await react(ctx, "๐Ÿ“");
168
+ // v4.12.3 โ€” If a background agent is pending, the running query is
169
+ // almost certainly just the SDK's CLI subprocess sitting idle waiting
170
+ // for the task-notification to be ready (can take 5+ minutes for long
171
+ // audits). Don't queue โ€” abort the blocked query and fall through so
172
+ // the new message gets processed immediately. The background task
173
+ // itself continues in its detached subprocess; the async-agent watcher
174
+ // delivers the result via subagent-delivery.ts when ready.
175
+ if (shouldBypassQueue({
176
+ isProcessing: session.isProcessing,
177
+ pendingBackgroundCount: session.pendingBackgroundCount,
178
+ abortController: session.abortController,
179
+ })) {
180
+ console.log(`[v4.12.3 bypass] aborting blocked query for ${sessionKey} โ€” ` +
181
+ `${session.pendingBackgroundCount} background agent(s) pending`);
182
+ // Mark the abort as a bypass so the old handler's error branch
183
+ // doesn't surface a "request cancelled" reply to the user.
184
+ session._bypassAbortFired = true;
185
+ try {
186
+ session.abortController.abort();
187
+ }
188
+ catch {
189
+ /* ignore */
190
+ }
191
+ // Wait briefly for the old handler's finally to run. If it hangs
192
+ // (>5s, shouldn't happen), we fall through anyway โ€” worst case is
193
+ // a brief overlap where both handlers run.
194
+ await waitUntilProcessingFalse(session, 5000);
195
+ // Fall through to start a fresh query below.
170
196
  }
171
197
  else {
172
- await ctx.reply("โณ Warteschlange voll (3 Nachrichten). Bitte warten oder /cancel.");
198
+ // Normal queue behavior. v4.12.3 โ€” emit a text reply in addition
199
+ // to the reaction so the user actually sees that their message
200
+ // was received and is waiting. Reactions alone are too subtle.
201
+ if (session.messageQueue.length < 3) {
202
+ session.messageQueue.push(text);
203
+ await react(ctx, "๐Ÿ“");
204
+ try {
205
+ await ctx.reply("โณ Eine Anfrage lรคuft gerade. Deine Nachricht ist in der Warteschlange und wird als Nรคchstes bearbeitet.");
206
+ }
207
+ catch {
208
+ /* harmless grammy race */
209
+ }
210
+ }
211
+ else {
212
+ await ctx.reply("โณ Warteschlange voll (3 Nachrichten). Bitte warten oder /cancel.");
213
+ }
214
+ return;
173
215
  }
174
- return;
175
216
  }
176
217
  // Consume queued messages (sent while previous query was processing)
177
218
  if (session.messageQueue.length > 0) {
@@ -180,9 +221,23 @@ export async function handleMessage(ctx) {
180
221
  }
181
222
  session.isProcessing = true;
182
223
  session.abortController = new AbortController();
224
+ // v4.12.3 โ€” Clear any stale bypass flag from a previous aborted turn.
225
+ // The flag is set by the bypass path right before it calls abort(),
226
+ // read by the OLD handler's error path, and cleared here by the NEW
227
+ // handler so it doesn't misclassify future non-bypass aborts. Use
228
+ // `delete` so TypeScript doesn't narrow the flag to literal `false`
229
+ // for the rest of this function (it's mutated from the bypass path in
230
+ // another handler invocation, so the type stays `boolean | undefined`).
231
+ delete session._bypassAbortFired;
183
232
  const streamer = new TelegramStreamer(ctx.chat.id, ctx.api, ctx.message?.message_id);
184
233
  let finalText = "";
185
234
  let timedOut = false;
235
+ // v4.12.3 โ€” Tracks whether the current turn ended because the bypass
236
+ // path aborted us. When true, skip the finalize/broadcast/๐Ÿ‘ reaction
237
+ // flow at the bottom of the handler since the user isn't waiting on
238
+ // this turn anymore. Explicit `boolean` type so TS doesn't narrow to
239
+ // the literal `false` and reject the later comparison.
240
+ let bypassAborted = false;
186
241
  const typingInterval = setInterval(() => {
187
242
  ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => { });
188
243
  }, 4000);
@@ -280,22 +335,49 @@ export async function handleMessage(ctx) {
280
335
  session.checkpointHintsInjected++;
281
336
  }
282
337
  }
338
+ // v4.12.3 โ€” If a background agent is still pending, skip SDK resume.
339
+ // The OLD SDK session is blocked waiting to deliver the
340
+ // task-notification inline; resuming it would inherit that block.
341
+ // Start a fresh SDK session and rely on the bridge preamble below
342
+ // to carry recent history so Claude has context.
343
+ const bypassResume = isSDK && shouldBypassSdkResume({
344
+ pendingBackgroundCount: session.pendingBackgroundCount,
345
+ });
346
+ if (bypassResume) {
347
+ console.log(`[v4.12.3 bypass] starting fresh SDK session for ${sessionKey} โ€” ` +
348
+ `${session.pendingBackgroundCount} background agent(s) still pending`);
349
+ }
283
350
  // B2 Bridge-Message: if SDK is active but there are non-SDK turns since
284
351
  // the last SDK turn, prepend a catch-up preamble so the SDK sees what
285
352
  // happened during the failover. We defensively clamp the index against
286
353
  // history bounds in case compaction shrank the array under our feet.
354
+ //
355
+ // v4.12.3 โ€” Bypass-resume path also gets a bridge: since we're starting
356
+ // a fresh SDK session, Claude has no prior context from this chat.
357
+ // Bridge the last BYPASS_BRIDGE_TURNS entries so it knows what we were
358
+ // just talking about.
359
+ const BYPASS_BRIDGE_TURNS = 10;
287
360
  let bridgedPrompt = text;
288
361
  if (isSDK) {
289
- const anchor = Math.min(session.lastSdkHistoryIndex, session.history.length - 1);
290
- const gapStart = Math.max(0, anchor + 1);
291
- // gapEnd excludes the user message we just added (history.length - 1).
292
- const gapEnd = session.history.length - 1;
362
+ let gapStart;
363
+ let gapEnd;
364
+ if (bypassResume) {
365
+ gapEnd = session.history.length - 1;
366
+ gapStart = Math.max(0, gapEnd - BYPASS_BRIDGE_TURNS);
367
+ }
368
+ else {
369
+ const anchor = Math.min(session.lastSdkHistoryIndex, session.history.length - 1);
370
+ gapStart = Math.max(0, anchor + 1);
371
+ // gapEnd excludes the user message we just added (history.length - 1).
372
+ gapEnd = session.history.length - 1;
373
+ }
293
374
  if (gapEnd > gapStart) {
294
375
  const gapTurns = session.history.slice(gapStart, gapEnd);
295
376
  const bridge = buildBridgeMessage(gapTurns);
296
377
  if (bridge) {
297
378
  bridgedPrompt = bridge + text;
298
- console.log(`[bridge] SDK recovery: injecting ${gapTurns.length} fallback turn(s) into prompt`);
379
+ console.log(`[bridge] ${bypassResume ? "bypass" : "SDK recovery"}: ` +
380
+ `injecting ${gapTurns.length} turn(s) into prompt`);
299
381
  }
300
382
  }
301
383
  }
@@ -307,8 +389,8 @@ export async function handleMessage(ctx) {
307
389
  abortSignal: session.abortController.signal,
308
390
  // User's UI locale โ€” registry uses it to localize failure messages.
309
391
  locale: session.language,
310
- // SDK-specific
311
- sessionId: isSDK ? session.sessionId : null,
392
+ // SDK-specific. v4.12.3 โ€” bypass resume when background pending.
393
+ sessionId: isSDK && !bypassResume ? session.sessionId : null,
312
394
  // Unified history: SDK ignores it (uses filesystem-resume instead),
313
395
  // non-SDK providers use it for context. Keeping it populated for both
314
396
  // means a failover from SDK โ†’ Ollama keeps the conversation context.
@@ -418,9 +500,12 @@ export async function handleMessage(ctx) {
418
500
  // hand them off to the async-agent watcher. The watcher will
419
501
  // poll the outputFile and deliver the result as a separate
420
502
  // Telegram message when the background agent finishes.
503
+ // v4.12.3 โ€” Forward sessionKey so the watcher can route the
504
+ // delivery-complete decrement back to the right session.
421
505
  handleToolResultChunk(chunk, {
422
506
  chatId: ctx.chat.id,
423
507
  userId,
508
+ sessionKey,
424
509
  lastToolUseInput: lastAgentToolUseInput,
425
510
  });
426
511
  // Reset the captured input โ€” only the immediately following
@@ -447,6 +532,15 @@ export async function handleMessage(ctx) {
447
532
  await ctx.reply(`โšก _${chunk.failedProvider} unavailable โ€” switching to ${chunk.providerName}_`, { parse_mode: "Markdown" });
448
533
  break;
449
534
  case "error":
535
+ // v4.12.3 โ€” If the bypass path aborted us, swallow the error
536
+ // silently. The new handler is already preparing to process
537
+ // the user's next message; showing a cancellation notice here
538
+ // would be misleading.
539
+ if (session._bypassAbortFired === true &&
540
+ chunk.error?.toLowerCase().includes("abort")) {
541
+ bypassAborted = true;
542
+ break;
543
+ }
450
544
  // If our stuck-timer fired, the abort travels up as a registry
451
545
  // mid-stream error chunk. Prefer the explicit stuck message over
452
546
  // the generic one so the user understands this was a real hang,
@@ -460,6 +554,11 @@ export async function handleMessage(ctx) {
460
554
  break;
461
555
  }
462
556
  }
557
+ if (bypassAborted) {
558
+ // v4.12.3 โ€” Bypass path took over; don't finalize, don't react ๐Ÿ‘.
559
+ // Just clean up and return. The finally block still fires.
560
+ return;
561
+ }
463
562
  await streamer.finalize(finalText);
464
563
  emit("message:sent", { userId, text: finalText, platform: "telegram" });
465
564
  // v4.5.0: tell observers the response is complete.
@@ -499,14 +598,26 @@ export async function handleMessage(ctx) {
499
598
  catch (err) {
500
599
  const errorMsg = err instanceof Error ? err.message : String(err);
501
600
  const lang = session.language;
502
- await react(ctx, "๐Ÿ‘Ž");
503
- if (timedOut) {
601
+ // v4.12.3 โ€” If this handler was interrupted by the bypass path
602
+ // (another handler aborted us to process a new message while a
603
+ // background agent is pending), silently absorb the abort error.
604
+ // Showing "request cancelled" would be misleading โ€” from the
605
+ // user's point of view, nothing was cancelled, their new message
606
+ // is just being processed.
607
+ const absorbBypassAbort = errorMsg.includes("abort") && session._bypassAbortFired === true;
608
+ if (absorbBypassAbort) {
609
+ // Do NOT react ๐Ÿ‘Ž or reply โ€” just clean up silently.
610
+ }
611
+ else if (timedOut) {
612
+ await react(ctx, "๐Ÿ‘Ž");
504
613
  await ctx.reply(t("bot.error.timeoutStuck", lang, { min: STUCK_TIMEOUT_MINUTES }));
505
614
  }
506
615
  else if (errorMsg.includes("abort")) {
616
+ await react(ctx, "๐Ÿ‘Ž");
507
617
  await ctx.reply(t("bot.error.requestCancelled", lang));
508
618
  }
509
619
  else if (!isHarmlessTelegramError(err)) {
620
+ await react(ctx, "๐Ÿ‘Ž");
510
621
  // Drop benign grammy races ("message is not modified", etc.)
511
622
  // instead of surfacing them as "Fehler: ..." replies.
512
623
  await ctx.reply(`${t("bot.error.prefix", lang)} ${errorMsg}`);
@@ -26,6 +26,7 @@ import fs from "fs";
26
26
  import { dirname } from "path";
27
27
  import { parseOutputFileStatus } from "./async-agent-parser.js";
28
28
  import { ASYNC_AGENTS_STATE_FILE } from "../paths.js";
29
+ import { getAllSessions } from "./session.js";
29
30
  /** How often the polling loop runs against each pending agent. */
30
31
  const POLL_INTERVAL_MS = 15_000;
31
32
  /** Hard ceiling per agent โ€” 12h. After this, give up and deliver
@@ -81,10 +82,32 @@ export function registerPendingAgent(input) {
81
82
  lastCheckedAt: 0,
82
83
  giveUpAt: input.giveUpAt ?? now + MAX_AGENT_AGE_MS,
83
84
  toolUseId: input.toolUseId,
85
+ sessionKey: input.sessionKey,
84
86
  };
85
87
  pending.set(input.agentId, entry);
86
88
  saveToDisk();
87
89
  }
90
+ /**
91
+ * v4.12.3 โ€” Decrement the session's pendingBackgroundCount. Called on
92
+ * every delivery (completed/failed/timeout). Clamped at 0 so drift
93
+ * scenarios (counter was already 0, or session was reset) never crash.
94
+ * Missing/unknown sessionKey โ†’ no-op. Never throws.
95
+ */
96
+ function decrementPendingCount(sessionKey) {
97
+ if (!sessionKey)
98
+ return;
99
+ try {
100
+ const all = getAllSessions();
101
+ const s = all.get(sessionKey);
102
+ if (!s)
103
+ return;
104
+ s.pendingBackgroundCount = Math.max(0, (s.pendingBackgroundCount ?? 0) - 1);
105
+ }
106
+ catch (err) {
107
+ // Never let a decrement failure break delivery.
108
+ console.error("[async-watcher] decrement failed:", err);
109
+ }
110
+ }
88
111
  /** Returns a snapshot of in-memory pending agents (for /subagents + diagnostics). */
89
112
  export function listPendingAgents() {
90
113
  return [...pending.values()];
@@ -167,6 +190,7 @@ async function deliverAsCompleted(entry, output, tokensUsed) {
167
190
  catch (err) {
168
191
  console.error(`[async-watcher] delivery failed for ${entry.agentId}:`, err);
169
192
  }
193
+ decrementPendingCount(entry.sessionKey);
170
194
  }
171
195
  async function deliverAsFailure(entry, status, error) {
172
196
  const { deliverSubAgentResult } = await import("./subagent-delivery.js");
@@ -194,6 +218,7 @@ async function deliverAsFailure(entry, status, error) {
194
218
  catch (err) {
195
219
  console.error(`[async-watcher] failure delivery failed for ${entry.agentId}:`, err);
196
220
  }
221
+ decrementPendingCount(entry.sessionKey);
197
222
  }
198
223
  // โ”€โ”€ Test helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
199
224
  /** Test-only: drop in-memory state. Doesn't touch disk. */
@@ -191,6 +191,11 @@ export function loadPersistedSessions() {
191
191
  compactionCount: 0,
192
192
  checkpointHintsInjected: 0,
193
193
  sdkSubTaskCount: 0,
194
+ // v4.12.3 โ€” Don't persist pendingBackgroundCount. On restart, the
195
+ // async-agent-watcher re-hydrates its own state file and polls each
196
+ // pending agent's outputFile, which handles delivery independently.
197
+ // Starting at 0 avoids stale counters surviving a crash.
198
+ pendingBackgroundCount: 0,
194
199
  history: Array.isArray(persisted.history) ? persisted.history : [],
195
200
  language: persisted.language ?? "en",
196
201
  messageQueue: [],
@@ -94,6 +94,7 @@ export function getSession(key) {
94
94
  compactionCount: 0,
95
95
  checkpointHintsInjected: 0,
96
96
  sdkSubTaskCount: 0,
97
+ pendingBackgroundCount: 0,
97
98
  history: [],
98
99
  language: "en",
99
100
  messageQueue: [],
@@ -122,6 +123,7 @@ export function resetSession(key) {
122
123
  session.compactionCount = 0;
123
124
  session.checkpointHintsInjected = 0;
124
125
  session.sdkSubTaskCount = 0;
126
+ session.pendingBackgroundCount = 0;
125
127
  session.history = [];
126
128
  session.lastSdkHistoryIndex = -1;
127
129
  session.startedAt = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.12.2",
3
+ "version": "4.12.3",
4
4
  "description": "Alvin Bot \u2014 Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -50,6 +50,119 @@ describe("async agent chunk flow (Stage 2)", () => {
50
50
  expect(r.outputFile).toBe("/tmp/out-abc-1.jsonl");
51
51
  });
52
52
 
53
+ it("v4.12.3 โ€” passes sessionKey to registerPendingAgent and increments session counter", async () => {
54
+ const registered: Array<{ sessionKey?: string }> = [];
55
+ vi.doMock("../src/services/async-agent-watcher.js", () => ({
56
+ registerPendingAgent: (input: { sessionKey?: string }) =>
57
+ registered.push(input),
58
+ startWatcher: () => {},
59
+ stopWatcher: () => {},
60
+ pollOnce: async () => {},
61
+ listPendingAgents: () => [],
62
+ }));
63
+
64
+ const { getSession } = await import("../src/services/session.js");
65
+ const session = getSession("v412-chunk-test-session");
66
+ session.pendingBackgroundCount = 0;
67
+
68
+ const { handleToolResultChunk } = await import(
69
+ "../src/handlers/async-agent-chunk-handler.js"
70
+ );
71
+ handleToolResultChunk(
72
+ {
73
+ type: "tool_result",
74
+ toolUseId: "toolu_sess",
75
+ toolResultContent:
76
+ "Async agent launched successfully.\n" +
77
+ "agentId: ag-sess\n" +
78
+ "output_file: /tmp/ag-sess.jsonl\n",
79
+ },
80
+ {
81
+ chatId: 10,
82
+ userId: 20,
83
+ sessionKey: "v412-chunk-test-session",
84
+ lastToolUseInput: { description: "SEO", prompt: "do it" },
85
+ },
86
+ );
87
+
88
+ expect(registered).toHaveLength(1);
89
+ expect(registered[0].sessionKey).toBe("v412-chunk-test-session");
90
+ expect(session.pendingBackgroundCount).toBe(1);
91
+ });
92
+
93
+ it("v4.12.3 โ€” multiple async launches in same turn stack the counter", async () => {
94
+ vi.doMock("../src/services/async-agent-watcher.js", () => ({
95
+ registerPendingAgent: () => {},
96
+ startWatcher: () => {},
97
+ stopWatcher: () => {},
98
+ pollOnce: async () => {},
99
+ listPendingAgents: () => [],
100
+ }));
101
+
102
+ const { getSession } = await import("../src/services/session.js");
103
+ const session = getSession("v412-chunk-stack");
104
+ session.pendingBackgroundCount = 0;
105
+
106
+ const { handleToolResultChunk } = await import(
107
+ "../src/handlers/async-agent-chunk-handler.js"
108
+ );
109
+
110
+ for (let i = 0; i < 3; i++) {
111
+ handleToolResultChunk(
112
+ {
113
+ type: "tool_result",
114
+ toolUseId: `toolu_${i}`,
115
+ toolResultContent:
116
+ `Async agent launched successfully.\n` +
117
+ `agentId: ag-${i}\n` +
118
+ `output_file: /tmp/ag-${i}.jsonl\n`,
119
+ },
120
+ {
121
+ chatId: 10,
122
+ userId: 20,
123
+ sessionKey: "v412-chunk-stack",
124
+ lastToolUseInput: { description: `task ${i}`, prompt: "p" },
125
+ },
126
+ );
127
+ }
128
+
129
+ expect(session.pendingBackgroundCount).toBe(3);
130
+ });
131
+
132
+ it("v4.12.3 โ€” non-async tool_result does not increment the counter", async () => {
133
+ vi.doMock("../src/services/async-agent-watcher.js", () => ({
134
+ registerPendingAgent: () => {
135
+ throw new Error("should not be called");
136
+ },
137
+ startWatcher: () => {},
138
+ stopWatcher: () => {},
139
+ pollOnce: async () => {},
140
+ listPendingAgents: () => [],
141
+ }));
142
+
143
+ const { getSession } = await import("../src/services/session.js");
144
+ const session = getSession("v412-chunk-nonasync");
145
+ session.pendingBackgroundCount = 0;
146
+
147
+ const { handleToolResultChunk } = await import(
148
+ "../src/handlers/async-agent-chunk-handler.js"
149
+ );
150
+ handleToolResultChunk(
151
+ {
152
+ type: "tool_result",
153
+ toolUseId: "toolu_read",
154
+ toolResultContent: "plain read result โ€” no async_launched marker",
155
+ },
156
+ {
157
+ chatId: 1,
158
+ userId: 1,
159
+ sessionKey: "v412-chunk-nonasync",
160
+ lastToolUseInput: { description: "read", prompt: "p" },
161
+ },
162
+ );
163
+ expect(session.pendingBackgroundCount).toBe(0);
164
+ });
165
+
53
166
  it("falls back to a generic description when no toolUseInput is provided", async () => {
54
167
  const registered: unknown[] = [];
55
168
  vi.doMock("../src/services/async-agent-watcher.js", () => ({