diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index eb29ada009a..ed55af639c6 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -941,18 +941,22 @@ public struct AgentWaitParams: Codable, Sendable { public struct WakeParams: Codable, Sendable { public let mode: AnyCodable public let text: String + public let sessionkey: String? public init( mode: AnyCodable, - text: String) + text: String, + sessionkey: String?) { self.mode = mode self.text = text + self.sessionkey = sessionkey } private enum CodingKeys: String, CodingKey { case mode case text + case sessionkey = "sessionKey" } } diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 244ff5dcd4c..c8506c21b54 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -1796,6 +1796,25 @@ export function wake( reason: "wake", ...(sessionKey ? { sessionKey } : {}), }); + } else if (sessionKey) { + // next-heartbeat + sessionKey still needs a targeted immediate wake. + // Reasons: + // 1. The regularly-scheduled heartbeat fires for the agent's main + // session, not the supplied sessionKey, so it never peeks the queue + // we just enqueued — the event would sit stranded indefinitely. + // 2. An `intent: "event"` wake gets deferred by heartbeat-runner as + // not-due and is not retried (only busy-skips are), so it cannot + // stand in for the regular cadence either. + // Effectively, --session-key collapses --mode now and --mode next-heartbeat + // into the same targeted-immediate behavior — this matches the documented + // user intent (target a specific session for relay) better than silently + // dropping the event. + state.deps.requestHeartbeat({ + source: "manual", + intent: "immediate", + reason: "wake", + sessionKey, + }); } return { ok: true } as const; } diff --git a/src/cron/service/wake.test.ts b/src/cron/service/wake.test.ts index 19e65b8a940..0f6c7e76f9e 100644 --- a/src/cron/service/wake.test.ts +++ b/src/cron/service/wake.test.ts @@ -55,7 +55,11 @@ describe("wake (cron timer)", () => { }); }); - it("threads sessionKey to enqueue only on mode=next-heartbeat", () => { + it("threads sessionKey to enqueue and fires a targeted immediate wake on mode=next-heartbeat", () => { + // next-heartbeat + sessionKey collapses to immediate-targeted behavior: + // the regularly-scheduled heartbeat fires for agent-main and never peeks + // a non-main session queue, and an "event"-intent wake is not retried by + // the heartbeat runner. Targeted immediate is the only reliable path. const { state, enqueueSystemEvent, requestHeartbeat } = createState(); expect( wake(state, { @@ -67,6 +71,18 @@ describe("wake (cron timer)", () => { expect(enqueueSystemEvent).toHaveBeenCalledWith("ping", { sessionKey: "agent:main:slack:42", }); + expect(requestHeartbeat).toHaveBeenCalledWith({ + source: "manual", + intent: "immediate", + reason: "wake", + sessionKey: "agent:main:slack:42", + }); + }); + + it("does not fire a wake on mode=next-heartbeat when no sessionKey is supplied", () => { + const { state, enqueueSystemEvent, requestHeartbeat } = createState(); + expect(wake(state, { mode: "next-heartbeat", text: "ping" })).toEqual({ ok: true }); + expect(enqueueSystemEvent).toHaveBeenCalledWith("ping", undefined); expect(requestHeartbeat).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index affd9f7cb02..74c29134ca8 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -36,6 +36,7 @@ import type { } from "../plugins/hook-types.js"; import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; +import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { dispatchGatewayCronFinishedNotifications, sendGatewayCronFailureAlert, @@ -201,17 +202,25 @@ export function buildGatewayCronService(params: { typeof opts?.agentId === "string" && opts.agentId.trim() ? normalizeAgentId(opts.agentId) : undefined; - const derivedAgentId = - requestedAgentId ?? - (opts?.sessionKey + // Derive agentId from sessionKey only when the key is agent-prefixed + // (`agent::...`). For relative session keys like `discord:channel:ops`, + // `resolveAgentIdFromSessionKey` returns the literal `DEFAULT_AGENT_ID` + // ("main") regardless of cfg, which would route relative keys to the + // hardcoded "main" agent even on non-main-default deployments. Passing + // `undefined` lets `resolveCronAgent` fall back to the configured default + // agent so wake and enqueue resolve to the same target. + const parsedSessionKeyAgentId = + opts?.sessionKey && parseAgentSessionKey(opts.sessionKey) ? normalizeAgentId(resolveAgentIdFromSessionKey(opts.sessionKey)) - : undefined); - const runtimeConfigBase = getRuntimeConfig(); - const runtimeConfig = - derivedAgentId !== undefined - ? mergeRuntimeAgentConfig(runtimeConfigBase, derivedAgentId) - : runtimeConfigBase; - const agentId = derivedAgentId || undefined; + : undefined; + const requestedOrDerived = requestedAgentId ?? parsedSessionKeyAgentId; + // Always run `resolveCronAgent`, including when no agent is requested — + // for relative session keys (e.g. `discord:channel:ops`) we want the + // configured default agent's session, which `resolveCronAgent(undefined)` + // returns. Leaving agentId undefined here would strand the wake on the + // resolveCronSessionKey branch below. + const { agentId: resolvedAgentId, cfg: runtimeConfig } = resolveCronAgent(requestedOrDerived); + const agentId = resolvedAgentId || undefined; const sessionKey = opts?.sessionKey && agentId ? resolveCronSessionKey({ @@ -282,9 +291,15 @@ export function buildGatewayCronService(params: { // When the caller passes only a sessionKey (e.g. `system event --session-key`), // derive agentId from that key so a non-default agent's session is not silently // rejected as foreign by resolveCronSessionKey and rerouted to the default agent. + // Only derive from agent-prefixed keys — for relative keys, let resolveCronAgent + // fall back to the configured default so we don't hardcode "main" on multi-agent + // deployments where main exists but isn't the default. (Mirrors the same fix in + // resolveCronWakeTarget above.) const derivedAgentId = opts?.agentId ?? - (opts?.sessionKey ? resolveAgentIdFromSessionKey(opts.sessionKey) : undefined); + (opts?.sessionKey && parseAgentSessionKey(opts.sessionKey) + ? resolveAgentIdFromSessionKey(opts.sessionKey) + : undefined); const { agentId, cfg: runtimeConfig } = resolveCronAgent(derivedAgentId); const sessionKey = resolveCronSessionKey({ runtimeConfig,