From a1d484d87706eec24640dd3e59d6d78bfcbb2d4b Mon Sep 17 00:00:00 2001 From: Daniel Alkurdi Date: Sun, 12 Apr 2026 20:57:30 +1000 Subject: [PATCH] fix(cron): preserve isolated agent workspace on reload (#65085) Merged via squash. Prepared head SHA: 44699cdc032f71f7ece30a25a03e6ee13951b9fb Co-authored-by: l0cka <13148507+l0cka@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc --- CHANGELOG.md | 1 + src/gateway/server-cron.test.ts | 184 ++++++++++++++++++++++++++++++++ src/gateway/server-cron.ts | 62 +++++++++-- 3 files changed, 236 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f19135c707..a28745bb73c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 739a9c8f471..7eed3540a67 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -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("../config/config.js"); return { @@ -83,6 +93,7 @@ describe("buildGatewayCronService", () => { beforeEach(() => { enqueueSystemEventMock.mockClear(); requestHeartbeatNowMock.mockClear(); + runHeartbeatOnceMock.mockClear(); loadConfigMock.mockClear(); fetchWithSsrFGuardMock.mockClear(); runCronIsolatedAgentTurnMock.mockClear(); @@ -260,4 +271,177 @@ describe("buildGatewayCronService", () => { state.cron.stop(); } }); + + it("preserves explicit isolated agent workspace when runtime reload config is stale", async () => { + const tmpDir = path.join(os.tmpdir(), `server-cron-agent-workspace-${Date.now()}`); + const startupCfg = { + session: { + mainKey: "main", + }, + cron: { + store: path.join(tmpDir, "cron.json"), + }, + agents: { + defaults: { + workspace: path.join(tmpDir, "workspace"), + }, + list: [ + { id: "main", default: true }, + { id: "yinze", workspace: path.join(tmpDir, "workspace-yinze") }, + ], + }, + } as OpenClawConfig; + const reloadedCfg = { + session: { + mainKey: "main", + }, + cron: { + store: path.join(tmpDir, "cron.json"), + }, + agents: { + defaults: { + workspace: path.join(tmpDir, "workspace"), + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + loadConfigMock.mockReturnValue(reloadedCfg); + + const state = buildGatewayCronService({ + cfg: startupCfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + try { + const job = await state.cron.add({ + name: "isolated-subagent-workspace", + enabled: true, + schedule: { kind: "at", at: new Date(1).toISOString() }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + agentId: "yinze", + payload: { kind: "agentTurn", message: "read SOW.md" }, + }); + + await state.cron.run(job.id, "force"); + + expect(runCronIsolatedAgentTurnMock).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "yinze", + cfg: expect.objectContaining({ + agents: expect.objectContaining({ + list: expect.arrayContaining([ + expect.objectContaining({ + id: "yinze", + workspace: path.join(tmpDir, "workspace-yinze"), + }), + ]), + }), + }), + }), + ); + } finally { + 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; + }) => Promise; + }; + }; + }).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(); + } + }); }); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 739cdd65dc5..0d9b49a8600 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -153,19 +153,52 @@ export function buildGatewayCronService(params: { const storePath = resolveCronStorePath(params.cfg.cron?.store); const cronEnabled = process.env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false; + const findAgentEntry = (cfg: OpenClawConfig, agentId: string) => + Array.isArray(cfg.agents?.list) + ? cfg.agents.list.find( + (entry) => + entry && typeof entry.id === "string" && normalizeAgentId(entry.id) === agentId, + ) + : undefined; + + const hasConfiguredAgent = (cfg: OpenClawConfig, agentId: string) => + Boolean(findAgentEntry(cfg, agentId)); + + const mergeRuntimeAgentConfig = (runtimeConfig: OpenClawConfig, requestedAgentId: string) => { + if (hasConfiguredAgent(runtimeConfig, requestedAgentId)) { + return runtimeConfig; + } + const fallbackAgentEntry = findAgentEntry(params.cfg, requestedAgentId); + if (!fallbackAgentEntry) { + return runtimeConfig; + } + const startupAgents = params.cfg.agents; + const runtimeAgents = runtimeConfig.agents; + return { + ...runtimeConfig, + agents: { + ...startupAgents, + ...runtimeAgents, + defaults: { + ...startupAgents?.defaults, + ...runtimeAgents?.defaults, + }, + list: [...(runtimeAgents?.list ?? []), fallbackAgentEntry], + }, + }; + }; + const resolveCronAgent = (requested?: string | null) => { const runtimeConfig = loadConfig(); const normalized = typeof requested === "string" && requested.trim() ? normalizeAgentId(requested) : undefined; - const hasAgent = - normalized !== undefined && - Array.isArray(runtimeConfig.agents?.list) && - runtimeConfig.agents.list.some( - (entry) => - entry && typeof entry.id === "string" && normalizeAgentId(entry.id) === normalized, - ); - const agentId = hasAgent ? normalized : resolveDefaultAgentId(runtimeConfig); - return { agentId, cfg: runtimeConfig }; + const effectiveConfig = + normalized !== undefined ? mergeRuntimeAgentConfig(runtimeConfig, normalized) : runtimeConfig; + const agentId = + normalized !== undefined && hasConfiguredAgent(effectiveConfig, normalized) + ? normalized + : resolveDefaultAgentId(effectiveConfig); + return { agentId, cfg: effectiveConfig }; }; const resolveCronSessionKey = (params: { @@ -203,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