mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:54:47 +00:00
fix: preserve cron session transcript rotation (#82200)
* fix: preserve cron session transcript rotation * chore: refresh pr checks
This commit is contained in:
committed by
GitHub
parent
c6ddb1afb7
commit
65dd71d42d
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user