Fix cron sessionFile persistence for isolated runs (#65203)

Merged via squash.

Prepared head SHA: c45230d2ed
Co-authored-by: samrusani <14844597+samrusani@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
Sam
2026-04-12 12:58:43 +02:00
committed by GitHub
parent a1d484d877
commit 50375ab31a
7 changed files with 54 additions and 5 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- 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.
- Cron/isolated sessions: persist the right transcript path for each isolated run, including fresh session rollovers, so cron runs stop appending to stale session files. Thanks @samrusani and @vincentkoc.
## 2026.4.11

View File

@@ -15,6 +15,9 @@ import {
runCronTurn,
withTempHome,
} from "./isolated-agent.turn-test-helpers.js";
import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-helpers.js";
setupRunCronIsolatedAgentTurnSuite();
describe("runCronIsolatedAgentTurn session identity", () => {
beforeEach(() => {
@@ -101,6 +104,22 @@ describe("runCronIsolatedAgentTurn session identity", () => {
});
});
it("passes sessionFile to isolated cron runs", async () => {
await withTempHome(async (home) => {
await runCronTurn(home, {
jobPayload: DEFAULT_AGENT_TURN_PAYLOAD,
});
const call = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0] as {
sessionFile?: string;
};
expect(call?.sessionFile).toContain(
path.join(home, ".openclaw", "agents", "main", "sessions"),
);
expect(call?.sessionFile?.endsWith(".jsonl")).toBe(true);
});
});
it("starts a fresh session id for each cron run", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });

View File

@@ -60,7 +60,10 @@ export function expectEmbeddedProviderModel(expected: { provider: string; model:
export async function readSessionEntry(storePath: string, key: string) {
const raw = await fs.readFile(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, { sessionId?: string; label?: string }>;
const store = JSON.parse(raw) as Record<
string,
{ sessionId?: string; label?: string; sessionFile?: string }
>;
return store[key];
}

View File

@@ -67,10 +67,13 @@ export function createCronPromptExecutor(params: {
abortSignal?: AbortSignal;
abortReason: () => string;
}) {
const sessionFile = resolveSessionTranscriptPath(
params.cronSession.sessionEntry.sessionId,
params.agentId,
);
const sessionFile =
params.cronSession.sessionEntry.sessionFile?.trim() ||
resolveSessionTranscriptPath(params.cronSession.sessionEntry.sessionId, params.agentId);
// Fallback for callers that bypass prepareCronRunContext before persisting retries.
if (!params.cronSession.sessionEntry.sessionFile?.trim()) {
params.cronSession.sessionEntry.sessionFile = sessionFile;
}
const cronFallbacksOverride = resolveCronFallbacksOverride({
cfg: params.cfg,
job: params.job,

View File

@@ -18,6 +18,7 @@ import {
} from "./helpers.js";
import { resolveCronModelSelection } from "./model-selection.js";
import { buildCronAgentDefaultsConfig } from "./run-config.js";
import { resolveSessionTranscriptPath } from "./run-execution.runtime.js";
import { executeCronRun, type CronExecutionResult } from "./run-executor.js";
import {
createPersistCronSessionEntry,
@@ -272,6 +273,9 @@ async function prepareCronRunContext(params: {
forceNew: input.job.sessionTarget === "isolated",
});
const runSessionId = cronSession.sessionEntry.sessionId;
if (!cronSession.sessionEntry.sessionFile?.trim()) {
cronSession.sessionEntry.sessionFile = resolveSessionTranscriptPath(runSessionId, agentId);
}
const runSessionKey = baseSessionKey.startsWith("cron:")
? `${agentSessionKey}:run:${runSessionId}`
: agentSessionKey;

View File

@@ -167,6 +167,24 @@ describe("resolveCronSession", () => {
expect(clearBootstrapSnapshot).toHaveBeenCalledWith("webhook:stable-key");
});
it("clears stale sessionFile when forceNew rolls to a fresh session", () => {
const result = resolveWithStoredEntry({
entry: {
sessionId: "existing-session-id-456",
updatedAt: NOW_MS - 1000,
sessionFile: "/tmp/stale-session.jsonl",
modelOverride: "sonnet-4",
},
fresh: true,
forceNew: true,
});
expect(result.sessionEntry.sessionId).not.toBe("existing-session-id-456");
expect(result.isNewSession).toBe(true);
expect(result.sessionEntry.sessionFile).toBeUndefined();
expect(result.sessionEntry.modelOverride).toBe("sonnet-4");
});
it("clears delivery routing metadata and deliveryContext when forceNew is true", () => {
const result = resolveWithStoredEntry({
entry: {

View File

@@ -83,6 +83,7 @@ export function resolveCronSession(params: {
lastAccountId: undefined,
lastThreadId: undefined,
deliveryContext: undefined,
sessionFile: undefined,
}),
};
return { storePath, store, sessionEntry, systemSent, isNewSession };