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,