From 80538c607d893e7e354be665ad5eb422c3969868 Mon Sep 17 00:00:00 2001 From: Bryan Marty Date: Sun, 8 Mar 2026 19:47:18 +0000 Subject: [PATCH] fix: deliver restart notice explicitly before agent resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore deliverOutboundPayloads() to send the restart summary deterministically before calling agentCommand() for the agent resume turn. Previously the notice was only delivered as input to the model via agentCommand(), making it model-dependent: if the model rewrote or omitted the content, the user would never see the restart summary/note. The new two-step flow: 1. deliverOutboundPayloads() — guaranteed delivery of the exact restart notice (model-independent). Restores the Slack replyToId mapping from main that ensures threaded replies land in the right thread. 2. agentCommand() — agent resume turn so the agent can continue autonomously and optionally provide additional context. Update test to assert deliverOutboundPayloads fires before agentCommand and verify the two-step ordering is preserved. --- src/gateway/server-restart-sentinel.test.ts | 30 ++++++++++++++++- src/gateway/server-restart-sentinel.ts | 37 +++++++++++++++++---- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index c247e8d0d6a..24a00296189 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({ })), normalizeChannelId: vi.fn((channel: string) => channel), resolveOutboundTarget: vi.fn(() => ({ ok: true as const, to: "+15550002" })), + deliverOutboundPayloads: vi.fn(async () => undefined), + buildOutboundSessionContext: vi.fn(() => ({ agentId: "main", sessionKey: "agent:main:main" })), agentCommand: vi.fn(async () => undefined), enqueueSystemEvent: vi.fn(), defaultRuntime: {}, @@ -69,6 +71,14 @@ vi.mock("../infra/outbound/targets.js", () => ({ resolveOutboundTarget: mocks.resolveOutboundTarget, })); +vi.mock("../infra/outbound/deliver.js", () => ({ + deliverOutboundPayloads: mocks.deliverOutboundPayloads, +})); + +vi.mock("../infra/outbound/session-context.js", () => ({ + buildOutboundSessionContext: mocks.buildOutboundSessionContext, +})); + vi.mock("../commands/agent.js", () => ({ agentCommand: mocks.agentCommand, })); @@ -88,9 +98,21 @@ describe("scheduleRestartSentinelWake", () => { vi.clearAllMocks(); }); - it("calls agentCommand with resolved channel, to, and sessionKey after restart", async () => { + it("delivers restart notice directly then resumes agent after restart", async () => { await scheduleRestartSentinelWake({ deps: {} as never }); + // Step 1: deterministic delivery (model-independent) + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "whatsapp", + to: "+15550002", + accountId: "acct-2", + payloads: [{ text: "restart message" }], + bestEffort: true, + }), + ); + + // Step 2: agent resume turn expect(mocks.agentCommand).toHaveBeenCalledWith( expect.objectContaining({ message: "restart message", @@ -105,6 +127,12 @@ describe("scheduleRestartSentinelWake", () => { mocks.defaultRuntime, {}, ); + + // Verify delivery happened before resume + const deliverOrder = mocks.deliverOutboundPayloads.mock.invocationCallOrder[0]; + const agentOrder = mocks.agentCommand.mock.invocationCallOrder[0]; + expect(deliverOrder).toBeLessThan(agentOrder); + expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index dadf596d6ad..6c603456aa0 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -4,6 +4,8 @@ import type { CliDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import { resolveMainSessionKeyFromConfig } from "../config/sessions.js"; import { parseSessionThreadInfo } from "../config/sessions/delivery-info.js"; +import { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; +import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { consumeRestartSentinel, @@ -76,14 +78,35 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { sessionThreadId ?? (origin?.threadId != null ? String(origin.threadId) : undefined); + // Step 1: deliver the restart notice deterministically — model-independent, guaranteed. + // Slack uses replyToId (thread_ts) for threading; deliverOutboundPayloads does not do + // this mapping automatically, so we convert here. See #17716. + const isSlack = channel === "slack"; + const replyToId = isSlack && threadId != null && threadId !== "" ? String(threadId) : undefined; + const resolvedThreadId = isSlack ? undefined : threadId; + const outboundSession = buildOutboundSessionContext({ cfg, sessionKey }); + try { + await deliverOutboundPayloads({ + cfg, + channel, + to: resolved.to, + accountId: origin?.accountId, + replyToId, + threadId: resolvedThreadId, + payloads: [{ text: message }], + session: outboundSession, + bestEffort: true, + }); + } catch { + // bestEffort: true means this should not throw, but guard anyway + } + + // Step 2: trigger an agent resume turn so the agent can continue autonomously + // after restart. The model sees the restart context and can respond/take actions. + // This is safe post-restart: scheduleRestartSentinelWake() runs in the new process + // with zero in-flight replies, so the pre-restart race condition (ab4a08a82) does + // not apply here. try { - // Use agentCommand() rather than deliverOutboundPayloads() so the restart - // message is a proper agent turn: the user is notified AND the agent sees - // the message in its conversation history and can resume autonomously. - // - // This is safe post-restart because scheduleRestartSentinelWake() runs in - // the new process, where there are zero in-flight replies. The pre-restart - // race condition fixed in ab4a08a82 does not apply here. await agentCommand( { message,