fix(cron): preserve stale-reload heartbeat config

This commit is contained in:
Vincent Koc
2026-04-12 11:22:21 +01:00
parent 94306164bc
commit 44699cdc03
3 changed files with 128 additions and 7 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Telegram/direct sessions: keep commentary-only assistant fallback payloads out of visible direct delivery, so Codex planning chatter cannot leak into Telegram DMs when a run has no `final_answer` text. (#65112) Thanks @vincentkoc.
- Infra/net: fix multipart FormData fields (including `model`) being silently dropped when a guarded runtime fetch body crosses a FormData implementation boundary, restoring OpenAI audio transcription requests that failed with HTTP 400. (#64349) Thanks @petr-sloup.
- Plugins/memory: restore cached memory capability public artifacts on plugin-registry cache hits so memory-backed artifact surfaces stay visible after warm loads. Thanks @sercada and @vincentkoc.
- Gateway/cron: preserve requested isolated-agent config across runtime reloads so subagent jobs and heartbeat overrides keep the right workspace and heartbeat settings when the hot-loaded snapshot is stale. Thanks @l0cka and @vincentkoc.
## 2026.4.11

View File

@@ -9,6 +9,7 @@ import { mergeMockedModule } from "../test-utils/vitest-module-mocks.js";
const {
enqueueSystemEventMock,
requestHeartbeatNowMock,
runHeartbeatOnceMock,
loadConfigMock,
fetchWithSsrFGuardMock,
runCronIsolatedAgentTurnMock,
@@ -16,6 +17,7 @@ const {
} = vi.hoisted(() => ({
enqueueSystemEventMock: vi.fn(),
requestHeartbeatNowMock: vi.fn(),
runHeartbeatOnceMock: vi.fn(async () => ({ status: "ran" as const, durationMs: 1 })),
loadConfigMock: vi.fn(),
fetchWithSsrFGuardMock: vi.fn(),
runCronIsolatedAgentTurnMock: vi.fn(async () => ({ status: "ok" as const, summary: "ok" })),
@@ -30,6 +32,10 @@ function requestHeartbeatNow(...args: unknown[]) {
return requestHeartbeatNowMock(...args);
}
function runHeartbeatOnce(...args: unknown[]) {
return runHeartbeatOnceMock(...args);
}
vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent,
}));
@@ -45,6 +51,10 @@ vi.mock("../infra/heartbeat-wake.js", async () => {
);
});
vi.mock("../infra/heartbeat-runner.js", () => ({
runHeartbeatOnce,
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return {
@@ -83,6 +93,7 @@ describe("buildGatewayCronService", () => {
beforeEach(() => {
enqueueSystemEventMock.mockClear();
requestHeartbeatNowMock.mockClear();
runHeartbeatOnceMock.mockClear();
loadConfigMock.mockClear();
fetchWithSsrFGuardMock.mockClear();
runCronIsolatedAgentTurnMock.mockClear();
@@ -333,4 +344,104 @@ describe("buildGatewayCronService", () => {
state.cron.stop();
}
});
it("preserves agent heartbeat overrides when runtime reload config is stale", async () => {
const tmpDir = path.join(os.tmpdir(), `server-cron-agent-heartbeat-${Date.now()}`);
const startupCfg = {
session: {
mainKey: "main",
},
cron: {
store: path.join(tmpDir, "cron.json"),
},
agents: {
defaults: {
workspace: path.join(tmpDir, "workspace"),
heartbeat: {
target: "main",
deliveryFormat: "text",
},
},
list: [
{ id: "main", default: true },
{
id: "yinze",
workspace: path.join(tmpDir, "workspace-yinze"),
heartbeat: {
target: "last",
deliveryFormat: "markdown",
},
},
],
},
} as OpenClawConfig;
const reloadedCfg = {
session: {
mainKey: "main",
},
cron: {
store: path.join(tmpDir, "cron.json"),
},
agents: {
defaults: {
workspace: path.join(tmpDir, "workspace"),
heartbeat: {
target: "main",
deliveryFormat: "text",
},
},
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
loadConfigMock.mockReturnValue(reloadedCfg);
const state = buildGatewayCronService({
cfg: startupCfg,
deps: {} as CliDeps,
broadcast: () => {},
});
try {
const cronDeps = (state.cron as unknown as {
state?: {
deps?: {
runHeartbeatOnce?: (opts?: {
agentId?: string;
sessionKey?: string | null;
heartbeat?: Record<string, unknown>;
}) => Promise<unknown>;
};
};
}).state?.deps;
await cronDeps?.runHeartbeatOnce?.({
agentId: "yinze",
sessionKey: "agent:yinze:main",
heartbeat: {},
});
expect(runHeartbeatOnceMock).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "yinze",
cfg: expect.objectContaining({
agents: expect.objectContaining({
list: expect.arrayContaining([
expect.objectContaining({
id: "yinze",
heartbeat: expect.objectContaining({
target: "last",
deliveryFormat: "markdown",
}),
}),
]),
}),
}),
heartbeat: expect.objectContaining({
target: "last",
deliveryFormat: "markdown",
}),
}),
);
} finally {
state.cron.stop();
}
});
});

View File

@@ -172,16 +172,18 @@ export function buildGatewayCronService(params: {
if (!fallbackAgentEntry) {
return runtimeConfig;
}
const startupAgents = params.cfg.agents;
const runtimeAgents = runtimeConfig.agents;
return {
...runtimeConfig,
agents: {
...params.cfg.agents,
...runtimeConfig.agents,
...startupAgents,
...runtimeAgents,
defaults: {
...params.cfg.agents?.defaults,
...runtimeConfig.agents?.defaults,
...startupAgents?.defaults,
...runtimeAgents?.defaults,
},
list: [...(runtimeConfig.agents?.list ?? []), fallbackAgentEntry],
list: [...(runtimeAgents?.list ?? []), fallbackAgentEntry],
},
};
};
@@ -234,13 +236,20 @@ export function buildGatewayCronService(params: {
};
const resolveCronWakeTarget = (opts?: { agentId?: string; sessionKey?: string | null }) => {
const runtimeConfig = loadConfig();
const requestedAgentId = opts?.agentId ? resolveCronAgent(opts.agentId).agentId : undefined;
const requestedAgentId =
typeof opts?.agentId === "string" && opts.agentId.trim()
? normalizeAgentId(opts.agentId)
: undefined;
const derivedAgentId =
requestedAgentId ??
(opts?.sessionKey
? normalizeAgentId(resolveAgentIdFromSessionKey(opts.sessionKey))
: undefined);
const runtimeConfigBase = loadConfig();
const runtimeConfig =
derivedAgentId !== undefined
? mergeRuntimeAgentConfig(runtimeConfigBase, derivedAgentId)
: runtimeConfigBase;
const agentId = derivedAgentId || undefined;
const sessionKey =
opts?.sessionKey && agentId