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 +64 -0
- package/dist/handlers/async-agent-chunk-handler.js +17 -0
- package/dist/handlers/background-bypass.js +75 -0
- package/dist/handlers/message.js +127 -16
- package/dist/services/async-agent-watcher.js +25 -0
- package/dist/services/session-persistence.js +5 -0
- package/dist/services/session.js +2 -0
- package/package.json +1 -1
- package/test/async-agent-chunk-flow.test.ts +113 -0
- package/test/background-bypass-integration.test.ts +443 -0
- package/test/background-bypass-stress.test.ts +417 -0
- package/test/background-bypass.test.ts +127 -0
- package/test/session-pending-background.test.ts +59 -0
- package/test/watcher-pending-count.test.ts +228 -0
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
|
+
}
|
package/dist/handlers/message.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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]
|
|
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
|
-
|
|
503
|
-
|
|
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: [],
|
package/dist/services/session.js
CHANGED
|
@@ -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
|
@@ -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", () => ({
|