fix(heartbeat): respect configured sessions for monitor wakes

Carry forward the exact reviewed patch from openclaw/openclaw#116373 onto current main.

Fixes openclaw/openclaw#116205.

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
sloptop-the-terrible
2026-08-21 10:32:32 +00:00
committed by Amp
parent 9b37e538a8
commit 112c263675
3 changed files with 698 additions and 1 deletions
+105
View File
@@ -10,6 +10,7 @@ import { AgentDeletionCommitUncertainError } from "../agents/agent-lifecycle-reg
import type { CliDeps } from "../cli/deps.js";
import type { OpenClawConfig } from "../config/config.js";
import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js";
import { resolveHeartbeatSession } from "../infra/heartbeat-runner-session.js";
import { resolveSystemEventOptionsOwnerAgentId } from "../infra/system-event-ownership.js";
import {
getActiveGatewayRootWorkCount,
@@ -2718,6 +2719,110 @@ describe("buildGatewayCronService", () => {
}
});
it("defaults monitor wakes to heartbeat.session without overriding explicit wake sessions", () => {
const cfg = {
...createCronConfig("server-cron-heartbeat-session"),
agents: {
defaults: {
heartbeat: {
every: "5m",
session: "ops-heartbeat",
},
},
entries: {
primary: { default: true },
},
},
} as OpenClawConfig;
loadConfigMock.mockReturnValue(cfg);
const state = buildGatewayCronService({
cfg,
deps: {} as CliDeps,
broadcast: () => {},
});
try {
const cronDeps = (
state.cron as unknown as {
state?: {
deps?: {
requestHeartbeat?: (opts?: {
source?: string;
agentId?: string;
sessionKey?: string | null;
}) => void;
};
};
}
).state?.deps;
cronDeps?.requestHeartbeat?.({
source: "interval",
agentId: "primary",
});
const monitorWake = requireRecord(
callArg(requestHeartbeatMock, 0, 0, "monitor heartbeat request"),
"monitor heartbeat request",
);
expect(monitorWake).toMatchObject({
source: "interval",
agentId: "primary",
sessionKey: undefined,
});
expect(
resolveHeartbeatSession(
cfg,
"primary",
cfg.agents?.defaults?.heartbeat,
monitorWake.sessionKey as string | undefined,
).sessionKey,
).toBe("agent:primary:ops-heartbeat");
requestHeartbeatMock.mockClear();
cronDeps?.requestHeartbeat?.({ source: "cron", agentId: "primary" });
const cronEventWake = requireRecord(
callArg(requestHeartbeatMock, 0, 0, "cron event heartbeat request"),
"cron event heartbeat request",
);
expect(cronEventWake).toMatchObject({
source: "cron",
agentId: "primary",
sessionKey: "agent:primary:main",
});
requestHeartbeatMock.mockClear();
expect(
state.cron.wake({
mode: "now",
text: "wake now",
agentId: "primary",
sessionKey: "user-session",
}),
).toEqual({ ok: true });
const explicitWake = requireRecord(
callArg(requestHeartbeatMock, 0, 0, "explicit heartbeat request"),
"explicit heartbeat request",
);
expect(explicitWake).toMatchObject({
source: "manual",
agentId: "primary",
sessionKey: "agent:primary:user-session",
});
expect(
resolveHeartbeatSession(
cfg,
"primary",
cfg.agents?.defaults?.heartbeat,
explicitWake.sessionKey as string,
).sessionKey,
).toBe("agent:primary:user-session");
} finally {
state.cron.stop();
}
});
it("derives agentId symmetrically for enqueue and wake when only an agent-prefixed sessionKey is supplied", () => {
// Multi-agent setup where the configured default ("primary") is NOT the
// agent referenced in the sessionKey ("ops"). Pre-PR, enqueue went through
+4 -1
View File
@@ -785,12 +785,15 @@ export function buildGatewayCronService(params: {
...opts,
preserveUntargeted: opts?.source !== "manual",
});
// Monitor ticks choose agents.*.heartbeat.session in the runner; caller-targeted
// interval wakes keep their explicit session just like manual and event wakes.
const useConfiguredSession = opts?.source === "interval" && !opts.sessionKey?.trim();
requestHeartbeat({
source: opts?.source ?? "cron",
intent: opts?.intent ?? "event",
reason: opts?.reason,
agentId,
sessionKey,
sessionKey: useConfiguredSession ? undefined : sessionKey,
heartbeat: sanitizeCronHeartbeatOverride(opts?.heartbeat),
...(opts?.scheduledEveryMs !== undefined
? { scheduledEveryMs: opts.scheduledEveryMs }
@@ -0,0 +1,589 @@
import fs from "node:fs/promises";
import { createServer, type ServerResponse } from "node:http";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { markCompleteReplyConfig } from "../../../../src/auto-reply/reply/get-reply-fast-path.test-support.js";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
getRuntimeConfigSnapshot,
} from "../../../../src/config/config.js";
import { resetConfigOverrides } from "../../../../src/config/runtime-overrides.js";
import {
loadSessionEntry,
replaceSessionEntry,
} from "../../../../src/config/sessions/session-accessor.js";
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions/store-writer-state.js";
import type { OpenClawConfig } from "../../../../src/config/types.openclaw.js";
import { readSessionMessagesAsync } from "../../../../src/gateway/session-transcript-readers.js";
import {
disconnectGatewayClient,
startGatewayWithClient,
} from "../../../../src/gateway/test-helpers.e2e.js";
import { buildMockOpenAiResponsesProvider } from "../../../../src/gateway/test-openai-responses-model.js";
import { resetAgentEventsForTest } from "../../../../src/infra/agent-events.js";
import { peekSystemEvents, resetSystemEventsForTest } from "../../../../src/infra/system-events.js";
import { resetTaskRegistryForTests } from "../../../../src/tasks/task-runtime.test-helpers.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../../../../src/test-utils/env.js";
import { normalizeSessionDeliveryState } from "../../../../src/utils/delivery-context.shared.js";
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
const PROOF_CHANNEL_ID = "heartbeat-route-proof";
const ISOLATED_GATEWAY_ENV_KEYS = [
"HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_TEST_GATEWAY_OVERRIDE_TOKEN",
"OPENCLAW_TEST_RUNTIME_OVERRIDE_TOKEN",
"OPENCLAW_TEST_MINIMAL_GATEWAY",
"OPENCLAW_SKIP_CHANNELS",
"OPENCLAW_SKIP_GMAIL_WATCHER",
"OPENCLAW_SKIP_CRON",
"OPENCLAW_SKIP_CANVAS_HOST",
"OPENCLAW_SKIP_BROWSER_CONTROL_SERVER",
"OPENCLAW_SKIP_PROVIDERS",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
] as const;
type DeliveryTrace = {
accountId: string | null;
text: string;
threadId: string | number | null;
to: string;
};
let sequence = 0;
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function nextId(prefix: string): string {
return `${prefix}-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}-${sequence++}`;
}
function resetGatewayState(): void {
resetConfigOverrides();
clearRuntimeConfigSnapshot();
clearConfigCache();
clearSessionStoreCacheForTest();
resetAgentEventsForTest({ preserveListeners: true });
resetSystemEventsForTest();
resetTaskRegistryForTests({ persist: false });
}
function writeResponsesEvents(response: ServerResponse, events: unknown[]): void {
response.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-store",
connection: "keep-alive",
});
response.end(
`${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")}data: [DONE]\n\n`,
);
}
function writeAssistantResponse(response: ServerResponse, text: string): void {
const message = {
type: "message",
id: nextId("provider-message"),
role: "assistant",
status: "completed",
content: [{ type: "output_text", text, annotations: [] }],
};
writeResponsesEvents(response, [
{
type: "response.output_item.added",
output_index: 0,
item: { ...message, status: "in_progress", content: [] },
},
{ type: "response.output_item.done", output_index: 0, item: message },
{
type: "response.completed",
response: {
id: nextId("provider-response"),
status: "completed",
output: [message],
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
},
},
]);
}
async function writeRouteCapturePlugin(params: {
pluginDir: string;
tracePath: string;
}): Promise<void> {
await fs.mkdir(params.pluginDir, { recursive: true });
await fs.writeFile(
path.join(params.pluginDir, "openclaw.plugin.json"),
`${JSON.stringify(
{
id: PROOF_CHANNEL_ID,
activation: { onStartup: true },
channels: [PROOF_CHANNEL_ID],
configSchema: { type: "object", additionalProperties: false, properties: {} },
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
path.join(params.pluginDir, "index.cjs"),
[
'const fs = require("node:fs");',
"let sequence = 0;",
"module.exports = {",
` id: ${JSON.stringify(PROOF_CHANNEL_ID)},`,
" register(api) {",
" api.registerChannel({",
" plugin: {",
` id: ${JSON.stringify(PROOF_CHANNEL_ID)},`,
" meta: {",
` id: ${JSON.stringify(PROOF_CHANNEL_ID)},`,
' label: "Heartbeat Route Proof",',
' selectionLabel: "Heartbeat Route Proof",',
' docsPath: "/channels/heartbeat-route-proof",',
' blurb: "Captures heartbeat routes for Gateway boundary tests.",',
" },",
' capabilities: { chatTypes: ["direct"] },',
" config: {",
' listAccountIds: () => ["default"],',
' resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }),',
" isEnabled: () => true,",
" isConfigured: () => true,",
" },",
" outbound: {",
' deliveryMode: "direct",',
" sendText: async ({ to, text, accountId, threadId }) => {",
` fs.appendFileSync(${JSON.stringify(params.tracePath)}, JSON.stringify({`,
" to,",
" text,",
" accountId: accountId ?? null,",
" threadId: threadId ?? null,",
' }) + "\\n", "utf8");',
" sequence += 1;",
` return { channel: ${JSON.stringify(PROOF_CHANNEL_ID)}, messageId: \`proof-\${sequence}\` };`,
" },",
" },",
" },",
" });",
" },",
"};",
"",
].join("\n"),
"utf8",
);
}
async function readDeliveryTrace(filePath: string): Promise<DeliveryTrace[]> {
let raw: string;
try {
raw = await fs.readFile(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
return raw
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as DeliveryTrace);
}
async function readSessionTranscript(sessionKey: string): Promise<unknown[]> {
const entry = loadSessionEntry({ agentId: "main", sessionKey, readConsistency: "latest" });
if (!entry?.sessionId) {
throw new Error(`Session entry ${sessionKey} was not persisted`);
}
return await readSessionMessagesAsync(
{
agentId: "main",
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey,
},
{ mode: "full", reason: "heartbeat session routing Gateway boundary proof" },
);
}
describe("Gateway heartbeat session routing", () => {
beforeEach(resetGatewayState);
afterEach(resetGatewayState);
it(
"routes monitor wakes through heartbeat.session while preserving explicit wake sessions",
{ timeout: 90_000 },
async () => {
const envSnapshot = captureEnv([...ISOLATED_GATEWAY_ENV_KEYS]);
const tempHome = tempDirs.make("openclaw-gateway-heartbeat-routing-");
const stateDir = path.join(tempHome, ".openclaw");
const workspaceDir = path.join(tempHome, "workspace");
const pluginDir = path.join(workspaceDir, "plugins", PROOF_CHANNEL_ID);
const deliveryTracePath = path.join(tempHome, "heartbeat-deliveries.jsonl");
const bundledPluginsDir = path.join(tempHome, "empty-bundled-plugins");
const configPath = path.join(stateDir, "openclaw.json");
await Promise.all([
fs.mkdir(workspaceDir, { recursive: true }),
fs.mkdir(bundledPluginsDir, { recursive: true }),
fs.mkdir(path.dirname(configPath), { recursive: true }),
]);
await Promise.all([
fs.writeFile(
path.join(workspaceDir, "HEARTBEAT.md"),
"Process all pending system events and report what was handled.\n",
),
writeRouteCapturePlugin({ pluginDir, tracePath: deliveryTracePath }),
]);
const token = nextId("heartbeat-routing-token");
for (const [key, value] of Object.entries({
HOME: tempHome,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_GATEWAY_TOKEN: token,
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_CRON: "0",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_PROVIDERS: "1",
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
})) {
setTestEnvValue(key, value);
}
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
deleteTestEnvValue("OPENCLAW_TEST_MINIMAL_GATEWAY");
deleteTestEnvValue("OPENCLAW_SKIP_CHANNELS");
const configuredSessionKey = "agent:main:ops-heartbeat";
const configuredSessionId = nextId("configured-heartbeat-session");
const configuredEvent = nextId("configured-heartbeat-event");
const configuredReply = nextId("configured-heartbeat-reply");
const explicitSessionKey = "agent:main:user-session";
const explicitSessionId = nextId("explicit-heartbeat-session");
const explicitQueuedEvent = nextId("explicit-queued-event");
const explicitWakeText = nextId("explicit-wake-event");
const explicitReply = nextId("explicit-heartbeat-reply");
const mainSessionKey = "agent:main:main";
const mainSessionId = nextId("main-session");
const providerRequests: Array<Record<string, unknown>> = [];
const providerServer = createServer((request, response) => {
void (async () => {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
if (request.method !== "POST" || request.url !== "/v1/responses") {
response.writeHead(404).end();
return;
}
const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<
string,
unknown
>;
providerRequests.push(body);
const serialized = JSON.stringify(body);
writeAssistantResponse(
response,
serialized.includes(configuredEvent)
? configuredReply
: serialized.includes(explicitQueuedEvent) || serialized.includes(explicitWakeText)
? explicitReply
: nextId("unexpected-heartbeat-reply"),
);
})().catch((error: unknown) => {
response.writeHead(500).end(error instanceof Error ? error.message : String(error));
});
});
let gateway: Awaited<ReturnType<typeof startGatewayWithClient>> | undefined;
try {
await new Promise<void>((resolve, reject) => {
providerServer.once("error", reject);
providerServer.listen(0, "127.0.0.1", resolve);
});
const providerAddress = providerServer.address();
if (!providerAddress || typeof providerAddress === "string") {
throw new Error("mock OpenAI Responses server did not bind a loopback port");
}
const provider = buildMockOpenAiResponsesProvider(
`http://127.0.0.1:${providerAddress.port}/v1`,
"gpt-heartbeat-session-routing",
);
const config = {
agents: {
defaults: {
workspace: workspaceDir,
skipBootstrap: true,
heartbeat: { every: "24h", session: "ops-heartbeat", target: "last" },
model: { primary: provider.modelRef },
models: {
[provider.modelRef]: {
params: { transport: "sse", openaiWsWarmup: false },
},
},
},
entries: { main: { default: true } },
},
models: {
mode: "replace",
providers: {
[provider.providerId]: {
...provider.config,
models: provider.config.models.map((model) =>
Object.assign({}, model, { input: Array.from(model.input) }),
),
},
},
},
gateway: { auth: { mode: "token", token } },
plugins: {
enabled: true,
allow: [PROOF_CHANNEL_ID],
load: { paths: [pluginDir] },
entries: { [PROOF_CHANNEL_ID]: { enabled: true } },
slots: { memory: "none" },
},
} satisfies OpenClawConfig;
gateway = await startGatewayWithClient({
cfg: config,
configPath,
token,
clientDisplayName: "vitest-gateway-heartbeat-session-routing",
});
const runtimeConfig = getRuntimeConfigSnapshot();
if (!runtimeConfig) {
throw new Error("gateway runtime config snapshot was not initialized");
}
markCompleteReplyConfig(runtimeConfig, { runtimeMode: "full" });
const client = gateway.client;
const seedSession = async (params: {
sessionId: string;
sessionKey: string;
to: string;
}) => {
await replaceSessionEntry(
{ agentId: "main", sessionKey: params.sessionKey },
{
sessionId: params.sessionId,
updatedAt: Date.now(),
delivery: normalizeSessionDeliveryState({
context: {
channel: PROOF_CHANNEL_ID,
to: params.to,
accountId: "default",
},
}),
},
);
};
await seedSession({
sessionId: configuredSessionId,
sessionKey: configuredSessionKey,
to: "configured-destination",
});
await seedSession({
sessionId: explicitSessionId,
sessionKey: explicitSessionKey,
to: "explicit-destination",
});
await seedSession({
sessionId: mainSessionId,
sessionKey: mainSessionKey,
to: "main-destination",
});
await expect(
client.request<{ ok: boolean }>("system-event", {
text: configuredEvent,
sessionKey: configuredSessionKey,
wake: false,
}),
).resolves.toEqual({ ok: true });
expect(peekSystemEvents(configuredSessionKey)).toContain(configuredEvent);
const listed = await client.request<{
jobs: Array<{
agentId?: string;
declarationKey?: string;
enabled: boolean;
id: string;
payload: { kind: string };
sessionTarget: string;
}>;
}>("cron.list", { includeDisabled: true });
const monitor = listed.jobs.find((job) => job.declarationKey === "heartbeat:main");
expect(monitor).toMatchObject({
agentId: "main",
declarationKey: "heartbeat:main",
enabled: true,
payload: { kind: "heartbeat" },
sessionTarget: "main",
});
if (!monitor) {
throw new Error("system-owned main-agent heartbeat monitor was not listed");
}
const configuredRequestBaseline = providerRequests.length;
const configuredRun = await client.request<{
enqueued: boolean;
ok: boolean;
runId: string;
}>("cron.run", {
id: monitor.id,
mode: "force",
});
expect(configuredRun).toMatchObject({
ok: true,
enqueued: true,
runId: expect.any(String),
});
await expect
.poll(
async () => {
const history = await client.request<{
entries: Array<{ error?: string; runId?: string; status?: string }>;
}>("cron.runs", {
id: monitor.id,
runId: configuredRun.runId,
limit: 1,
});
return history.entries.find((entry) => entry.runId === configuredRun.runId);
},
{ timeout: 15_000, interval: 50 },
)
.toMatchObject({ runId: configuredRun.runId, status: "ok" });
await expect
.poll(() => providerRequests.length, { timeout: 15_000, interval: 50 })
.toBeGreaterThan(configuredRequestBaseline);
const configuredRequest = JSON.stringify(providerRequests[configuredRequestBaseline]);
expect(configuredRequest).toContain(configuredEvent);
await expect
.poll(() => peekSystemEvents(configuredSessionKey).includes(configuredEvent), {
timeout: 15_000,
interval: 50,
})
.toBe(false);
await expect
.poll(() => readDeliveryTrace(deliveryTracePath), { timeout: 15_000, interval: 50 })
.toHaveLength(1);
expect(await readDeliveryTrace(deliveryTracePath)).toEqual([
{
accountId: "default",
text: configuredReply,
threadId: null,
to: "configured-destination",
},
]);
await expect
.poll(() => readSessionTranscript(configuredSessionKey).then(JSON.stringify), {
timeout: 15_000,
interval: 50,
})
.toContain(configuredReply);
expect(
loadSessionEntry({
agentId: "main",
sessionKey: configuredSessionKey,
readConsistency: "latest",
})?.sessionId,
).toBe(configuredSessionId);
const configuredTranscript = JSON.stringify(
await readSessionTranscript(configuredSessionKey),
);
expect(configuredTranscript).toContain(configuredReply);
expect(JSON.stringify(await readSessionTranscript(mainSessionKey))).not.toContain(
configuredReply,
);
await expect(
client.request<{ ok: boolean }>("system-event", {
text: explicitQueuedEvent,
sessionKey: explicitSessionKey,
wake: false,
}),
).resolves.toEqual({ ok: true });
expect(peekSystemEvents(explicitSessionKey)).toContain(explicitQueuedEvent);
const explicitRequestBaseline = providerRequests.length;
await expect(
client.request<{ ok: boolean }>("wake", {
mode: "now",
text: explicitWakeText,
agentId: "main",
sessionKey: explicitSessionKey,
}),
).resolves.toEqual({ ok: true });
await expect
.poll(() => providerRequests.length, { timeout: 15_000, interval: 50 })
.toBeGreaterThan(explicitRequestBaseline);
const explicitRequest = JSON.stringify(providerRequests[explicitRequestBaseline]);
expect(explicitRequest).toContain(explicitQueuedEvent);
expect(explicitRequest).toContain(explicitWakeText);
await expect
.poll(
() => {
const queued = peekSystemEvents(explicitSessionKey);
return queued.includes(explicitQueuedEvent) || queued.includes(explicitWakeText);
},
{ timeout: 15_000, interval: 50 },
)
.toBe(false);
await expect
.poll(() => readDeliveryTrace(deliveryTracePath), { timeout: 15_000, interval: 50 })
.toHaveLength(2);
expect(await readDeliveryTrace(deliveryTracePath)).toEqual([
{
accountId: "default",
text: configuredReply,
threadId: null,
to: "configured-destination",
},
{
accountId: "default",
text: explicitReply,
threadId: null,
to: "explicit-destination",
},
]);
await expect
.poll(() => readSessionTranscript(explicitSessionKey).then(JSON.stringify), {
timeout: 15_000,
interval: 50,
})
.toContain(explicitReply);
expect(
loadSessionEntry({
agentId: "main",
sessionKey: explicitSessionKey,
readConsistency: "latest",
})?.sessionId,
).toBe(explicitSessionId);
const explicitTranscript = JSON.stringify(await readSessionTranscript(explicitSessionKey));
expect(explicitTranscript).toContain(explicitReply);
expect(explicitTranscript).not.toContain(configuredReply);
expect(JSON.stringify(await readSessionTranscript(configuredSessionKey))).not.toContain(
explicitReply,
);
const mainTranscript = JSON.stringify(await readSessionTranscript(mainSessionKey));
expect(mainTranscript).not.toContain(configuredReply);
expect(mainTranscript).not.toContain(explicitReply);
expect((await readDeliveryTrace(deliveryTracePath)).map((entry) => entry.to)).not.toContain(
"main-destination",
);
} finally {
if (gateway) {
await disconnectGatewayClient(gateway.client);
await gateway.server.close({ reason: "Gateway heartbeat session routing test complete" });
}
providerServer.closeAllConnections();
await new Promise<void>((resolve) => {
providerServer.close(() => resolve());
});
envSnapshot.restore();
}
},
);
});