fix: preserve cron session transcript rotation (#82200)

* fix: preserve cron session transcript rotation

* chore: refresh pr checks
This commit is contained in:
Peter Steinberger
2026-05-15 17:00:42 +01:00
committed by GitHub
parent c6ddb1afb7
commit 65dd71d42d
8 changed files with 216 additions and 4 deletions

View File

@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
- Providers/OpenRouter: stop adding empty DeepSeek V4 `reasoning_content` placeholders to assistant tool-call replay messages and strip empty replay artifacts before follow-up Chat Completions requests, so `openrouter/deepseek/deepseek-v4-pro` no longer fails after tool use. Fixes #82150. (#82158) Thanks @luyao618 and @Suquir0.
- Gateway/approvals: treat `turnSourceTo` as optional in `canBridgeNoDeviceChatApprovalFromBackend`, matching the existing optional handling of `turnSourceAccountId` and `turnSourceThreadId`. Channels without a recipient concept (webchat, control-ui) leave `turnSourceTo` null on both the approval snapshot and the replay params, so the prior required-string check rejected every backend replay with `APPROVAL_CLIENT_MISMATCH`. Cross-channel replay is still gated by the required `turnSourceChannel` and `sessionKey` checks. Fixes #82132. (#82136) Thanks @ottodeng.
- Cron: load runtime plugins before isolated cron model and delivery resolution so external channels can be selected for scheduled runs. (#82111) Thanks @medns.
- Cron: preserve rotated transcript identity after session-bound scheduled runs compact, so `sessionTarget: "current"` keeps the next user message on the same conversation. Fixes #82164. Thanks @weissfl.
- Twitch: keep gateway accounts running until shutdown instead of treating successful monitor startup as a clean channel exit, preventing immediate auto-restart loops. Fixes #60071. (#81853) Thanks @edenfunf.
- Agents/auto-reply: honor `agents.defaults.silentReply` and per-surface group silent-reply policy when generic agent-run failure fallbacks decide whether to send visible fallback text. Fixes #82060. (#82086) Thanks @taozengabc.
- Discord: render channel topic context as structured untrusted metadata in reply prompts and stop duplicating inbound message bodies or exposing raw `EXTERNAL_UNTRUSTED_CONTENT` envelopes. Fixes #82168. Thanks @ronan-dandelion-cult.

View File

@@ -1169,6 +1169,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: activeSessionId,
sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -1465,6 +1466,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: activeSessionId,
sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -1967,6 +1969,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -1997,6 +2000,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -2068,6 +2072,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,
@@ -2108,6 +2113,7 @@ export async function runEmbeddedPiAgent(
durationMs: Date.now() - started,
agentMeta: buildErrorAgentMeta({
sessionId: sessionIdUsed,
sessionFile: activeSessionFile,
provider,
model: model.id,
contextTokens: ctxInfo.tokens,

View File

@@ -1,6 +1,10 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { resolveFinalAssistantRawText, resolveFinalAssistantVisibleText } from "./helpers.js";
import {
buildErrorAgentMeta,
resolveFinalAssistantRawText,
resolveFinalAssistantVisibleText,
} from "./helpers.js";
function makeAssistantMessage(
content: AssistantMessage["content"],
@@ -73,3 +77,21 @@ describe("resolveFinalAssistantVisibleText", () => {
expect(resolveFinalAssistantRawText(lastAssistant)).toBe("<final>keep this</final>");
});
});
describe("buildErrorAgentMeta", () => {
it("preserves active session file for error exits after transcript rotation", () => {
expect(
buildErrorAgentMeta({
sessionId: "session-rotated",
sessionFile: "/tmp/session-rotated.jsonl",
provider: "anthropic",
model: "claude-opus-4-6",
usageAccumulator: {},
lastRunPromptUsage: undefined,
}),
).toMatchObject({
sessionId: "session-rotated",
sessionFile: "/tmp/session-rotated.jsonl",
});
});
});

View File

@@ -161,6 +161,7 @@ export function buildUsageAgentMetaFields(params: {
*/
export function buildErrorAgentMeta(params: {
sessionId: string;
sessionFile?: string;
provider: string;
model: string;
contextTokens?: number;
@@ -177,6 +178,7 @@ export function buildErrorAgentMeta(params: {
});
return {
sessionId: params.sessionId,
...(params.sessionFile ? { sessionFile: params.sessionFile } : {}),
provider: params.provider,
model: params.model,
...(params.contextTokens ? { contextTokens: params.contextTokens } : {}),

View File

@@ -4,7 +4,12 @@ import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as modelThinkingDefault from "../agents/model-thinking-default.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js";
import {
makeCfg,
makeJob,
writeSessionStore,
writeSessionStoreEntries,
} from "./isolated-agent.test-harness.js";
import {
DEFAULT_AGENT_TURN_PAYLOAD,
DEFAULT_MESSAGE,
@@ -18,6 +23,7 @@ import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-h
import {
mockRunCronFallbackPassthrough,
runEmbeddedPiAgentMock,
updateSessionStoreMock,
} from "./isolated-agent/run.test-harness.js";
setupRunCronIsolatedAgentTurnSuite();
@@ -142,6 +148,79 @@ describe("runCronIsolatedAgentTurn session identity", () => {
});
});
it("persists rotated transcript identity for session-bound cron runs", async () => {
await withTempHome(async (home) => {
const deps = makeDeps();
const boundSessionKey = "agent:main:telegram:direct:42";
const originalSessionFile = path.join(home, "bound-session.jsonl");
const rotatedSessionFile = path.join(home, "bound-session-rotated.jsonl");
const storePath = await writeSessionStoreEntries(home, {
[boundSessionKey]: {
sessionId: "bound-session",
sessionFile: originalSessionFile,
updatedAt: Date.now(),
lastInteractionAt: Date.now() - 1_000,
systemSent: true,
},
});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: {
sessionId: "bound-session-rotated",
sessionFile: rotatedSessionFile,
provider: "anthropic",
model: "claude-opus-4-6",
compactionCount: 1,
compactionTokensAfter: 42,
},
},
});
updateSessionStoreMock.mockImplementation(async (_storePath, update) => {
const store = {
[boundSessionKey]: {
sessionId: "bound-session",
sessionFile: originalSessionFile,
updatedAt: Date.now(),
lastInteractionAt: Date.now() - 1_000,
systemSent: true,
},
};
update(store);
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: {
...makeJob(DEFAULT_AGENT_TURN_PAYLOAD),
sessionTarget: `session:${boundSessionKey}`,
delivery: { mode: "none" },
},
message: DEFAULT_MESSAGE,
sessionKey: boundSessionKey,
lane: "cron",
});
expect(res.status).toBe("ok");
expect(res.sessionId).toBe("bound-session-rotated");
const finalPersist = updateSessionStoreMock.mock.calls.at(-1);
expect(finalPersist?.[0]).toBe(storePath);
const persistedStore: Record<string, { [key: string]: unknown }> = {};
(finalPersist?.[1] as (store: typeof persistedStore) => void)(persistedStore);
expect(persistedStore[boundSessionKey]).toEqual(
expect.objectContaining({
sessionId: "bound-session-rotated",
sessionFile: rotatedSessionFile,
usageFamilyKey: boundSessionKey,
usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
}),
);
});
});
it("uses lightweight bootstrap context for command-style cron payloads", async () => {
await withTempHome(async (home) => {
await runCronTurn(home, {

View File

@@ -4,7 +4,11 @@ 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";
import {
adoptCronRunSessionMetadata,
createPersistCronSessionEntry,
type MutableCronSession,
} from "./run-session-state.js";
function makeSessionEntry(overrides?: Partial<SessionEntry>): SessionEntry {
return {
@@ -159,6 +163,56 @@ describe("createPersistCronSessionEntry", () => {
expect(cronSession.store["agent:main:session"]).toBe(cronSession.sessionEntry);
});
it("adopts rotated run transcript metadata before persisting session-bound cron state", async () => {
const cronSession = makeCronSession(
makeSessionEntry({
sessionId: "bound-session",
sessionFile: "/tmp/bound-session.jsonl",
}),
);
const changed = adoptCronRunSessionMetadata({
entry: cronSession.sessionEntry,
sessionKey: "agent:main:telegram:direct:42",
runMeta: {
sessionId: "bound-session-rotated",
sessionFile: "/tmp/bound-session-rotated.jsonl",
},
});
const updateSessionStore = vi.fn(
async (_storePath, update: (store: Record<string, SessionEntry>) => void) => {
const store: Record<string, SessionEntry> = {};
update(store);
expect(store["agent:main:telegram:direct:42"]).toEqual({
sessionId: "bound-session-rotated",
sessionFile: "/tmp/bound-session-rotated.jsonl",
usageFamilyKey: "agent:main:telegram:direct:42",
usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
updatedAt: 1000,
systemSent: true,
});
},
);
expect(changed).toBe(true);
const persist = createPersistCronSessionEntry({
isFastTestEnv: false,
cronSession,
agentSessionKey: "agent:main:telegram:direct:42",
updateSessionStore,
});
await persist();
expect(cronSession.store["agent:main:telegram:direct:42"]).toEqual({
sessionId: "bound-session-rotated",
sessionFile: "/tmp/bound-session-rotated.jsonl",
usageFamilyKey: "agent:main:telegram:direct:42",
usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
updatedAt: 1000,
systemSent: true,
});
});
});
async function createTranscriptFile(): Promise<string> {

View File

@@ -26,6 +26,11 @@ function cronTranscriptExists(entry: SessionEntry): boolean {
return Boolean(sessionFile && fs.existsSync(sessionFile));
}
function normalizeSessionField(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function toNonResumableCronSessionEntry(entry: SessionEntry): SessionEntry {
const next = { ...entry } as Partial<SessionEntry>;
delete next.sessionId;
@@ -61,6 +66,43 @@ export function createPersistCronSessionEntry(params: {
};
}
export function adoptCronRunSessionMetadata(params: {
entry: MutableCronSessionEntry;
sessionKey: string;
runMeta?: {
sessionId?: string;
sessionFile?: string;
};
}): boolean {
const nextSessionId = normalizeSessionField(params.runMeta?.sessionId);
const nextSessionFile = normalizeSessionField(params.runMeta?.sessionFile);
if (!nextSessionFile) {
return false;
}
let changed = false;
const previousSessionId = params.entry.sessionId;
if (nextSessionId && nextSessionId !== previousSessionId) {
params.entry.sessionId = nextSessionId;
params.entry.usageFamilyKey = params.entry.usageFamilyKey ?? params.sessionKey;
params.entry.usageFamilySessionIds = Array.from(
new Set([
...(params.entry.usageFamilySessionIds ?? []),
...(previousSessionId ? [previousSessionId] : []),
nextSessionId,
]),
);
changed = true;
}
if (nextSessionFile !== params.entry.sessionFile) {
params.entry.sessionFile = nextSessionFile;
changed = true;
}
return changed;
}
export async function persistCronSkillsSnapshotIfChanged(params: {
isFastTestEnv: boolean;
cronSession: MutableCronSession;

View File

@@ -37,6 +37,7 @@ import {
import { resolveCronModelSelection } from "./model-selection.js";
import { buildCronAgentDefaultsConfig } from "./run-config.js";
import {
adoptCronRunSessionMetadata,
createPersistCronSessionEntry,
markCronSessionPreRun,
persistCronSkillsSnapshotIfChanged,
@@ -580,7 +581,7 @@ async function prepareCronRunContext(params: {
});
const withRunSession: WithRunSession = (result) => ({
...result,
sessionId: runSessionId,
sessionId: cronSession.sessionEntry.sessionId ?? runSessionId,
sessionKey: runSessionKey,
});
if (!cronSession.sessionEntry.label?.trim() && baseSessionKey.startsWith("cron:")) {
@@ -852,6 +853,11 @@ async function finalizeCronRun(params: {
if (finalRunResult.meta?.systemPromptReport) {
prepared.cronSession.sessionEntry.systemPromptReport = finalRunResult.meta.systemPromptReport;
}
adoptCronRunSessionMetadata({
entry: prepared.cronSession.sessionEntry,
sessionKey: prepared.agentSessionKey,
runMeta: finalRunResult.meta?.agentMeta,
});
const usage = finalRunResult.meta?.agentMeta?.usage;
const promptTokens = finalRunResult.meta?.agentMeta?.promptTokens;
const modelUsed =