fix(cron): keep pre-transcript rows non-resumable

Refs #77011.
This commit is contained in:
Peter Steinberger
2026-05-04 05:45:27 +01:00
committed by GitHub
parent 8d6db59cf7
commit dd83f72a7f
3 changed files with 115 additions and 2 deletions

View File

@@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai
- Google Meet: keep explicit non-Google `realtime.provider` values as the transcription provider compatibility fallback when `realtime.transcriptionProvider` is unset. Thanks @vincentkoc.
- Google Meet: make Twilio setup status require an enabled `voice-call` plugin entry instead of treating a missing entry as ready. Thanks @vincentkoc.
- Telegram: render shared interactive reply buttons in reply delivery so plugin approval messages show inline keyboards. (#76238) Thanks @keshavbotagent.
- Cron/sessions: keep cron metadata rows without an on-disk transcript non-resumable until a transcript exists, so doctor and `sessions cleanup --fix-missing` no longer report or prune pre-transcript cron rows as broken sessions. Refs #77011.
- Agents/cli-runner: drop a saved `claude-cli` resume sessionId at preparation time when its on-disk transcript no longer exists in `~/.claude/projects/`, so a stale binding from a half-installed `update.run` cannot trap follow-up runs (auto-reply / Telegram direct) in a `claude --resume` timeout loop; the run starts fresh and the new sessionId is written back through the existing post-run flow. (#77030; refs #77011) Thanks @openperf.
- Release validation: install the cross-OS TypeScript harness through Windows-safe Node/npm shims so native Windows package checks reach the OpenClaw smoke suites instead of exiting before artifact capture. Thanks @vincentkoc.
- Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc.

View File

@@ -1,3 +1,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import { createPersistCronSessionEntry, type MutableCronSession } from "./run-session-state.js";
@@ -26,6 +30,7 @@ describe("createPersistCronSessionEntry", () => {
it("persists isolated cron state only under the stable cron session key", async () => {
const cronSession = makeCronSession(
makeSessionEntry({
sessionFile: await createTranscriptFile(),
status: "running",
startedAt: 900,
skillsSnapshot: {
@@ -56,6 +61,81 @@ describe("createPersistCronSessionEntry", () => {
expect(cronSession.store["agent:main:cron:job:run:run-session-id"]).toBeUndefined();
});
it("does not register cron sessions as resumable until the transcript exists", async () => {
const missingTranscriptPath = path.join(
os.tmpdir(),
`openclaw-missing-cron-${crypto.randomUUID()}.jsonl`,
);
const cronSession = makeCronSession(
makeSessionEntry({
sessionFile: missingTranscriptPath,
label: "Cron: shell-only",
status: "running",
}),
);
const updateSessionStore = vi.fn(
async (_storePath, update: (store: Record<string, SessionEntry>) => void) => {
const store: Record<string, SessionEntry> = {};
update(store);
expect(store["agent:main:cron:shell-only"]).toEqual(
expect.objectContaining({
label: "Cron: shell-only",
status: "running",
updatedAt: 1000,
}),
);
expect(store["agent:main:cron:shell-only"]?.sessionId).toBeUndefined();
expect(store["agent:main:cron:shell-only"]?.sessionFile).toBeUndefined();
},
);
const persist = createPersistCronSessionEntry({
isFastTestEnv: false,
cronSession,
agentSessionKey: "agent:main:cron:shell-only",
updateSessionStore,
});
await persist();
expect(cronSession.store["agent:main:cron:shell-only"]?.sessionId).toBeUndefined();
expect(cronSession.store["agent:main:cron:shell-only"]?.sessionFile).toBeUndefined();
});
it("restores resumable cron fields once the transcript exists", async () => {
const transcriptPath = await createTranscriptFile();
const cronSession = makeCronSession(
makeSessionEntry({
sessionFile: transcriptPath,
label: "Cron: completed",
}),
);
const persist = createPersistCronSessionEntry({
isFastTestEnv: false,
cronSession,
agentSessionKey: "agent:main:cron:completed",
updateSessionStore: vi.fn(
async (_storePath, update: (store: Record<string, SessionEntry>) => void) => {
const store: Record<string, SessionEntry> = {};
update(store);
expect(store["agent:main:cron:completed"]).toMatchObject({
sessionId: "run-session-id",
sessionFile: transcriptPath,
label: "Cron: completed",
});
},
),
});
await persist();
expect(cronSession.store["agent:main:cron:completed"]).toMatchObject({
sessionId: "run-session-id",
sessionFile: transcriptPath,
});
});
it("persists explicit session-bound cron state under the requested session key", async () => {
const cronSession = makeCronSession();
const updateSessionStore = vi.fn(
@@ -78,3 +158,10 @@ describe("createPersistCronSessionEntry", () => {
expect(cronSession.store["agent:main:session"]).toBe(cronSession.sessionEntry);
});
});
async function createTranscriptFile(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-session-"));
const file = path.join(dir, "session.jsonl");
await fs.writeFile(file, `${JSON.stringify({ type: "session", sessionId: "run-session-id" })}\n`);
return file;
}

View File

@@ -1,6 +1,8 @@
import fs from "node:fs";
import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js";
import type { SkillSnapshot } from "../../agents/skills.js";
import type { SessionEntry } from "../../config/sessions.js";
import { isCronSessionKey } from "../../sessions/session-key-utils.js";
import type { resolveCronSession } from "./session.js";
type MutableSessionStore = Record<string, SessionEntry>;
@@ -19,6 +21,23 @@ type UpdateSessionStore = (
export type PersistCronSessionEntry = () => Promise<void>;
function cronTranscriptExists(entry: SessionEntry): boolean {
const sessionFile = entry.sessionFile?.trim();
return Boolean(sessionFile && fs.existsSync(sessionFile));
}
function toNonResumableCronSessionEntry(entry: SessionEntry): SessionEntry {
const next = { ...entry } as Partial<SessionEntry>;
delete next.sessionId;
delete next.sessionFile;
delete next.sessionStartedAt;
delete next.lastInteractionAt;
delete next.cliSessionIds;
delete next.cliSessionBindings;
delete next.claudeCliSessionId;
return next as SessionEntry;
}
export function createPersistCronSessionEntry(params: {
isFastTestEnv: boolean;
cronSession: MutableCronSession;
@@ -29,9 +48,15 @@ export function createPersistCronSessionEntry(params: {
if (params.isFastTestEnv) {
return;
}
params.cronSession.store[params.agentSessionKey] = params.cronSession.sessionEntry;
const persistedEntry =
isCronSessionKey(params.agentSessionKey) &&
params.cronSession.sessionEntry.sessionId &&
!cronTranscriptExists(params.cronSession.sessionEntry)
? toNonResumableCronSessionEntry(params.cronSession.sessionEntry)
: params.cronSession.sessionEntry;
params.cronSession.store[params.agentSessionKey] = persistedEntry;
await params.updateSessionStore(params.cronSession.storePath, (store) => {
store[params.agentSessionKey] = params.cronSession.sessionEntry;
store[params.agentSessionKey] = persistedEntry;
});
};
}