diff --git a/CHANGELOG.md b/CHANGELOG.md index 55aa98ac49d..9adf8d6a6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cron/isolated-agent/run-session-state.test.ts b/src/cron/isolated-agent/run-session-state.test.ts index 816c258967d..201483c01ef 100644 --- a/src/cron/isolated-agent/run-session-state.test.ts +++ b/src/cron/isolated-agent/run-session-state.test.ts @@ -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) => void) => { + const store: Record = {}; + 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) => void) => { + const store: Record = {}; + 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 { + 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; +} diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts index 23e9bbd11df..337a1422ec6 100644 --- a/src/cron/isolated-agent/run-session-state.ts +++ b/src/cron/isolated-agent/run-session-state.ts @@ -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; @@ -19,6 +21,23 @@ type UpdateSessionStore = ( export type PersistCronSessionEntry = () => Promise; +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; + 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; }); }; }