Skip to content

Commit 29142a9

Browse files
authored
fix: preserve Telegram topic routing for exec completions (#64580)
* clawdbot-a2c: pin exec completion delivery context Regeneration-Prompt: | Fix a Telegram forum topic misroute where delayed exec completion or similar async completion text could be delivered into the wrong topic after the session's stored route drifted. Keep the patch surgical. Preserve immutable origin deliveryContext when background exec completion events are queued, thread that context from the exec tool's ambient channel/session defaults into the process session, and ensure the queued system event carries it instead of relying on later heartbeat fallback to mutable session lastTo/lastThreadId data. Add one focused unit assertion that notifyOnExit events keep the original Telegram topic delivery context and one heartbeat regression that proves work started in topic 47 still delivers back to topic 47 even if the session store later points at topic 2175. * fix: note Telegram exec topic routing Regeneration-Prompt: | Prepare PR #64580 after review-pr with no blocking findings. The only required prep change was the workflow-mandated changelog entry under CHANGELOG.md -> Unreleased -> Fixes. Preserve the review conclusion that the code change is already acceptable, do not widen scope beyond the changelog, and include the PR number plus thanks attribution in the changelog line for the Telegram exec forum-topic completion routing fix.
1 parent a948e28 commit 29142a9

7 files changed

Lines changed: 133 additions & 8 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ Docs: https://docs.openclaw.ai
178178
- Models/vLLM: ignore empty `tool_calls` arrays from reasoning-model OpenAI-compatible replies, reset false `toolUse` stop reasons when no actual tool calls were parsed, and stop sending `tool_choice` unless tools are present so vLLM reasoning responses no longer hang indefinitely. (#61197, #61534) Thanks @balajisiva.
179179
- Heartbeat/scheduling: spread interval heartbeats across stable per-agent phases derived from gateway identity, so provider traffic is distributed more uniformly across the configured interval instead of clustering around startup-relative times. (#64560) Thanks @odysseus0.
180180
- Config/media: accept `tools.media.asyncCompletion.directSend` in strict config validation so gateways no longer reject the generated-schema-backed async media completion setting at startup. (#63618) Thanks @qiziAI.
181+
- Telegram/exec: preserve delayed exec completion routing for forum topics by pinning background exec completions to the topic where the run started even if the session route later drifts. (#64580) thanks @jalehman.
181182

182183
## 2026.4.9
183184

src/agents/bash-process-registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ChildProcessWithoutNullStreams } from "node:child_process";
2+
import type { DeliveryContext } from "../utils/delivery-context.js";
23
import { createSessionSlug as createSessionSlugId } from "./session-slug.js";
34

45
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
@@ -30,6 +31,7 @@ export interface ProcessSession {
3031
command: string;
3132
scopeKey?: string;
3233
sessionKey?: string;
34+
notifyDeliveryContext?: DeliveryContext;
3335
notifyOnExit?: boolean;
3436
notifyOnExitEmptySuccess?: boolean;
3537
exitNotified?: boolean;

src/agents/bash-tools.exec-runtime.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,21 @@ describe("emitExecSystemEvent", () => {
305305
emitExecSystemEvent("Exec finished", {
306306
sessionKey: "agent:ops:main",
307307
contextKey: "exec:run-1",
308+
deliveryContext: {
309+
channel: "telegram",
310+
to: "telegram:-100123:topic:47",
311+
threadId: 47,
312+
},
308313
});
309314

310315
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
311316
sessionKey: "agent:ops:main",
312317
contextKey: "exec:run-1",
318+
deliveryContext: {
319+
channel: "telegram",
320+
to: "telegram:-100123:topic:47",
321+
threadId: 47,
322+
},
313323
});
314324
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({
315325
reason: "exec-event",

src/agents/bash-tools.exec-runtime.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { logWarn } from "../logger.js";
2727
import type { ManagedRun } from "../process/supervisor/index.js";
2828
import { getProcessSupervisor } from "../process/supervisor/index.js";
2929
import type { RunExit, TerminationReason } from "../process/supervisor/types.js";
30+
import { normalizeDeliveryContext, type DeliveryContext } from "../utils/delivery-context.js";
3031
import {
3132
addSession,
3233
appendOutput,
@@ -343,7 +344,11 @@ function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "faile
343344
const summary = output
344345
? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}`
345346
: `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`;
346-
enqueueSystemEvent(summary, { sessionKey, trusted: false });
347+
enqueueSystemEvent(summary, {
348+
sessionKey,
349+
deliveryContext: session.notifyDeliveryContext,
350+
trusted: false,
351+
});
347352
requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }));
348353
}
349354

@@ -409,13 +414,17 @@ export function resolveApprovalRunningNoticeMs(value?: number) {
409414

410415
export function emitExecSystemEvent(
411416
text: string,
412-
opts: { sessionKey?: string; contextKey?: string },
417+
opts: { sessionKey?: string; contextKey?: string; deliveryContext?: DeliveryContext },
413418
) {
414419
const sessionKey = opts.sessionKey?.trim();
415420
if (!sessionKey) {
416421
return;
417422
}
418-
enqueueSystemEvent(text, { sessionKey, contextKey: opts.contextKey });
423+
enqueueSystemEvent(text, {
424+
sessionKey,
425+
contextKey: opts.contextKey,
426+
deliveryContext: opts.deliveryContext,
427+
});
419428
requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }));
420429
}
421430

@@ -547,6 +556,7 @@ export async function runExecProcess(opts: {
547556
notifyOnExitEmptySuccess?: boolean;
548557
scopeKey?: string;
549558
sessionKey?: string;
559+
notifyDeliveryContext?: DeliveryContext;
550560
timeoutSec: number | null;
551561
onUpdate?: (partialResult: AgentToolResult<ExecToolDetails>) => void;
552562
}): Promise<ExecProcessHandle> {
@@ -564,6 +574,7 @@ export async function runExecProcess(opts: {
564574
command: opts.command,
565575
scopeKey: opts.scopeKey,
566576
sessionKey: opts.sessionKey,
577+
notifyDeliveryContext: normalizeDeliveryContext(opts.notifyDeliveryContext),
567578
notifyOnExit: opts.notifyOnExit,
568579
notifyOnExitEmptySuccess: opts.notifyOnExitEmptySuccess === true,
569580
exitNotified: false,

src/agents/bash-tools.exec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
normalizeOptionalLowercaseString,
1717
normalizeOptionalString,
1818
} from "../shared/string-coerce.js";
19+
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
1920
import { splitShellArgs } from "../utils/shell-argv.js";
2021
import { markBackgrounded } from "./bash-process-registry.js";
2122
import { describeExecTool } from "./bash-tools.descriptions.js";
@@ -1322,6 +1323,12 @@ export function createExecTool(
13221323
const notifyOnExit = defaults?.notifyOnExit !== false;
13231324
const notifyOnExitEmptySuccess = defaults?.notifyOnExitEmptySuccess === true;
13241325
const notifySessionKey = normalizeOptionalString(defaults?.sessionKey);
1326+
const notifyDeliveryContext = normalizeDeliveryContext({
1327+
channel: defaults?.messageProvider,
1328+
to: defaults?.currentChannelId,
1329+
accountId: defaults?.accountId,
1330+
threadId: defaults?.currentThreadTs,
1331+
});
13251332
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
13261333
// Derive agentId only when sessionKey is an agent session key.
13271334
const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
@@ -1668,6 +1675,7 @@ export function createExecTool(
16681675
notifyOnExitEmptySuccess,
16691676
scopeKey: defaults?.scopeKey,
16701677
sessionKey: notifySessionKey,
1678+
notifyDeliveryContext,
16711679
timeoutSec: effectiveTimeout,
16721680
onUpdate,
16731681
});

src/agents/bash-tools.test.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,18 +206,18 @@ const requireRunningSessionId = (result: { details: unknown }) => {
206206
return requireSessionId(result.details as { sessionId?: string });
207207
};
208208

209-
function hasNotifyEventForPrefix(prefix: string): boolean {
210-
return peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY).some((event) => event.includes(prefix));
209+
function hasNotifyEventForPrefix(prefix: string, sessionKey = DEFAULT_NOTIFY_SESSION_KEY): boolean {
210+
return peekSystemEvents(sessionKey).some((event) => event.includes(prefix));
211211
}
212212

213-
async function waitForNotifyEvent(sessionId: string) {
213+
async function waitForNotifyEvent(sessionId: string, sessionKey = DEFAULT_NOTIFY_SESSION_KEY) {
214214
const prefix = sessionId.slice(0, 8);
215215
let finished = getFinishedSession(sessionId);
216-
let hasEvent = hasNotifyEventForPrefix(prefix);
216+
let hasEvent = hasNotifyEventForPrefix(prefix, sessionKey);
217217
await expect
218218
.poll(() => {
219219
finished = getFinishedSession(sessionId);
220-
hasEvent = hasNotifyEventForPrefix(prefix);
220+
hasEvent = hasNotifyEventForPrefix(prefix, sessionKey);
221221
return Boolean(finished && hasEvent);
222222
}, NOTIFY_POLL_OPTIONS)
223223
.toBe(true);
@@ -579,6 +579,32 @@ describe("exec notifyOnExit", () => {
579579
expect(formatted).toContain("System (untrusted):");
580580
});
581581

582+
it("preserves the origin delivery context on background exec completion events", async () => {
583+
const sessionKey = "agent:main:telegram:group:-1003774691294:topic:47";
584+
const tool = createNotifyOnExitExecTool({
585+
sessionKey,
586+
messageProvider: "telegram",
587+
currentChannelId: "telegram:-1003774691294:topic:47",
588+
currentThreadTs: "47",
589+
});
590+
591+
const sessionId = await startBackgroundCommand(tool, echoAfterDelay("notify"));
592+
593+
await waitForNotifyEvent(sessionId, sessionKey);
594+
const queuedEvent = peekSystemEventEntries(sessionKey).find((event) =>
595+
event.text.includes(sessionId.slice(0, 8)),
596+
);
597+
598+
expect(queuedEvent).toMatchObject({
599+
trusted: false,
600+
deliveryContext: {
601+
channel: "telegram",
602+
to: "telegram:-1003774691294:topic:47",
603+
threadId: "47",
604+
},
605+
});
606+
});
607+
582608
it("scopes notifyOnExit heartbeat wake to the exec session key", async () => {
583609
const tool = createNotifyOnExitExecTool();
584610
const wakeHandler = vi.fn().mockResolvedValue({ status: "skipped", reason: "disabled" });

src/infra/heartbeat-runner.ghost-reminder.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,4 +358,71 @@ describe("Ghost reminder bug (issue #13317)", () => {
358358
expect(options?.messageThreadId).toBeUndefined();
359359
});
360360
});
361+
362+
it("keeps exec-event delivery pinned to the original Telegram topic when session route drifts", async () => {
363+
await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => {
364+
const cfg: OpenClawConfig = {
365+
agents: {
366+
defaults: {
367+
workspace: tmpDir,
368+
heartbeat: {
369+
every: "5m",
370+
target: "last",
371+
},
372+
},
373+
},
374+
channels: { telegram: { allowFrom: ["*"] } },
375+
session: { store: storePath },
376+
};
377+
const sessionKey = "agent:main:telegram:group:-1003774691294:topic:47";
378+
await fs.writeFile(
379+
storePath,
380+
JSON.stringify({
381+
[sessionKey]: {
382+
sessionId: "sid",
383+
updatedAt: Date.now(),
384+
lastChannel: "telegram",
385+
lastTo: "telegram:-1003774691294:topic:2175",
386+
lastThreadId: 2175,
387+
},
388+
}),
389+
);
390+
391+
const sendTelegram = vi.fn().mockResolvedValue({
392+
messageId: "m1",
393+
chatId: "-1003774691294",
394+
});
395+
const getReplySpy = vi.fn().mockResolvedValue({
396+
text: "The review-worker spawn finished successfully.",
397+
});
398+
enqueueSystemEvent("Exec completed (review-run, code 0)", {
399+
sessionKey,
400+
trusted: false,
401+
deliveryContext: {
402+
channel: "telegram",
403+
to: "telegram:-1003774691294:topic:47",
404+
threadId: 47,
405+
},
406+
});
407+
408+
const result = await runHeartbeatOnce({
409+
cfg,
410+
agentId: "main",
411+
sessionKey,
412+
reason: "exec-event",
413+
deps: {
414+
getReplyFromConfig: getReplySpy,
415+
telegram: sendTelegram,
416+
},
417+
});
418+
419+
expect(result.status).toBe("ran");
420+
expect(sendTelegram).toHaveBeenCalledTimes(1);
421+
expect(sendTelegram).toHaveBeenCalledWith(
422+
"telegram:-1003774691294:topic:47",
423+
"The review-worker spawn finished successfully.",
424+
expect.objectContaining({ messageThreadId: 47 }),
425+
);
426+
});
427+
});
361428
});

0 commit comments

Comments
 (0)