mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
fix(cron): preserve isolated agent workspace on reload (#65085)
Merged via squash.
Prepared head SHA: 44699cdc03
Co-authored-by: l0cka <13148507+l0cka@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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<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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user