From 17f36351099f08c3f2530c4463e8d39775d31629 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 01:17:53 +0000 Subject: [PATCH] fix: preserve restart routing + thread replies (#1337) (thanks @John-Rood) Co-authored-by: John-Rood Co-authored-by: Outdoor --- CHANGELOG.md | 1 + src/agents/tools/gateway-tool.ts | 33 ++++++++++++++++++++++++++ src/commands/agent/types.ts | 2 +- src/gateway/server-restart-sentinel.ts | 31 ++++++++++++++++++++++-- src/infra/restart-sentinel.ts | 8 +++++++ 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9bf627f1..62b5f27e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.clawd.bot - Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk. - Doctor: clarify plugin auto-enable hint text in the startup banner. - Gateway: clarify unauthorized handshake responses with token/password mismatch guidance. +- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood. - Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner. - UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot. - UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest. diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 63236c77c..602fe4ec1 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -3,6 +3,8 @@ import crypto from "node:crypto"; import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; +import { loadConfig } from "../../config/io.js"; +import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { formatDoctorNonInteractiveHint, @@ -77,11 +79,42 @@ export function createGatewayTool(opts?: { : undefined; const note = typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; + // Extract channel + threadId for routing after restart + let deliveryContext: { channel?: string; to?: string; accountId?: string } | undefined; + let threadId: string | undefined; + if (sessionKey) { + const threadMarker = ":thread:"; + const threadIndex = sessionKey.lastIndexOf(threadMarker); + const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex); + const threadIdRaw = + threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length); + threadId = threadIdRaw?.trim() || undefined; + try { + const cfg = loadConfig(); + const storePath = resolveStorePath(cfg.session?.store); + const store = loadSessionStore(storePath); + let entry = store[sessionKey]; + if (!entry?.deliveryContext && threadIndex !== -1 && baseSessionKey) { + entry = store[baseSessionKey]; + } + if (entry?.deliveryContext) { + deliveryContext = { + channel: entry.deliveryContext.channel, + to: entry.deliveryContext.to, + accountId: entry.deliveryContext.accountId, + }; + } + } catch { + // ignore: best-effort + } + } const payload: RestartSentinelPayload = { kind: "restart", status: "ok", ts: Date.now(), sessionKey, + deliveryContext, + threadId, message: note ?? reason ?? null, doctorHint: formatDoctorNonInteractiveHint(), stats: { diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index ed18d0b45..b0afadd91 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -1,5 +1,5 @@ -import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js"; +import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; /** Image content block for Claude API multimodal messages. */ export type ImageContent = { diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index d0661c063..250322c2e 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -28,9 +28,30 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { return; } + const threadMarker = ":thread:"; + const threadIndex = sessionKey.lastIndexOf(threadMarker); + const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex); + const threadIdRaw = + threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length); + const sessionThreadId = threadIdRaw?.trim() || undefined; + const { cfg, entry } = loadSessionEntry(sessionKey); - const parsedTarget = resolveAnnounceTargetFromKey(sessionKey); - const origin = mergeDeliveryContext(deliveryContextFromSession(entry), parsedTarget ?? undefined); + const parsedTarget = resolveAnnounceTargetFromKey(baseSessionKey); + + // Prefer delivery context from sentinel (captured at restart) over session store + // Handles race condition where store wasn't flushed before restart + const sentinelContext = payload.deliveryContext; + let sessionDeliveryContext = deliveryContextFromSession(entry); + if (!sessionDeliveryContext && threadIndex !== -1 && baseSessionKey) { + const { entry: baseEntry } = loadSessionEntry(baseSessionKey); + sessionDeliveryContext = deliveryContextFromSession(baseEntry); + } + + const origin = mergeDeliveryContext( + sentinelContext, + mergeDeliveryContext(sessionDeliveryContext, parsedTarget ?? undefined), + ); + const channelRaw = origin?.channel; const channel = channelRaw ? normalizeChannelId(channelRaw) : null; const to = origin?.to; @@ -51,6 +72,11 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { return; } + const threadId = + payload.threadId ?? + sessionThreadId ?? + (origin?.threadId != null ? String(origin.threadId) : undefined); + try { await agentCommand( { @@ -61,6 +87,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { deliver: true, bestEffortDeliver: true, messageChannel: channel, + replyToId: threadId, }, defaultRuntime, params.deps, diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 760554b03..95c47c4d2 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -33,6 +33,14 @@ export type RestartSentinelPayload = { status: "ok" | "error" | "skipped"; ts: number; sessionKey?: string; + /** Delivery context captured at restart time to ensure channel routing survives restart. */ + deliveryContext?: { + channel?: string; + to?: string; + accountId?: string; + }; + /** Thread ID for reply threading (e.g., Slack thread_ts). */ + threadId?: string; message?: string | null; doctorHint?: string | null; stats?: RestartSentinelStats | null;