fix(agents): heartbeat always targets main session — prevent routing to active subagent sessions (#61803)

Merged via squash.

Prepared head SHA: 5d79db3940
Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
EVA
2026-04-07 23:59:18 +07:00
committed by GitHub
parent c6b5731c5d
commit caecd3c1fe
2 changed files with 93 additions and 1 deletions

View File

@@ -82,7 +82,7 @@ Docs: https://docs.openclaw.ai
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistrals Chat Completions API. (#62162) Thanks @neeravmakwana.
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
- BlueBubbles/network: respect explicit private-network opt-out for loopback and private `serverUrl` values across account resolution, status probes, monitor startup, and attachment downloads, while keeping public-host attachment hostname pinning intact. (#59373) Thanks @jpreagan.
- Agents/heartbeat: keep heartbeat runs pinned to the main session so active subagent transcripts are not overwritten by heartbeat status messages. (#61803) thanks @100yenadmin.
## 2026.4.5
### Breaking

View File

@@ -898,6 +898,98 @@ describe("runHeartbeatOnce", () => {
},
);
it.each([
{
name: "subagent key via forcedSessionKey (opts.sessionKey)",
injectVia: "opts" as const,
},
{
name: "subagent key via heartbeat.session config",
injectVia: "config" as const,
},
])("falls back to main session when subagent key enters via $name", async ({ injectVia }) => {
const replySpy = vi.fn();
try {
const tmpDir = await createCaseDir("hb-subagent-guard");
const storePath = path.join(tmpDir, "sessions.json");
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "last",
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const mainSessionKey = resolveMainSessionKey(cfg);
const agentId = resolveAgentIdFromSessionKey(mainSessionKey);
const subagentKey = `agent:${agentId}:subagent:task-abc`;
if (injectVia === "config" && cfg.agents?.defaults?.heartbeat) {
cfg.agents.defaults.heartbeat.session = subagentKey;
}
await fs.writeFile(
storePath,
JSON.stringify({
[mainSessionKey]: {
sessionId: "sid-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "120363401234567890@g.us",
},
[subagentKey]: {
sessionId: "sid-subagent",
updatedAt: Date.now() + 10_000,
lastChannel: "whatsapp",
lastTo: "99999@g.us",
},
}),
);
replySpy.mockClear();
replySpy.mockResolvedValue([{ text: "Main session heartbeat" }]);
const sendWhatsApp = vi
.fn<
(
to: string,
text: string,
opts?: unknown,
) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
await runHeartbeatOnce({
cfg,
...(injectVia === "opts" ? { sessionKey: subagentKey } : {}),
deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }),
});
// The heartbeat must use the main session, not the subagent session.
expect(replySpy).toHaveBeenCalledWith(
expect.objectContaining({
SessionKey: mainSessionKey,
}),
expect.anything(),
expect.anything(),
);
// Must NOT use the subagent session key.
expect(replySpy).not.toHaveBeenCalledWith(
expect.objectContaining({
SessionKey: subagentKey,
}),
expect.anything(),
expect.anything(),
);
} finally {
replySpy.mockReset();
}
});
it("suppresses duplicate heartbeat payloads within 24h", async () => {
const tmpDir = await createCaseDir("hb-dup-suppress");
const storePath = path.join(tmpDir, "sessions.json");