Skip to content

Commit 65dd71d

Browse files
authored
fix: preserve cron session transcript rotation (#82200)
* fix: preserve cron session transcript rotation * chore: refresh pr checks
1 parent c6ddb1a commit 65dd71d

8 files changed

Lines changed: 216 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
2020
- 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.
2121
- 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.
2222
- Cron: load runtime plugins before isolated cron model and delivery resolution so external channels can be selected for scheduled runs. (#82111) Thanks @medns.
23+
- 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.
2324
- 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.
2425
- 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.
2526
- 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.

src/agents/pi-embedded-runner/run.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,7 @@ export async function runEmbeddedPiAgent(
11691169
durationMs: Date.now() - started,
11701170
agentMeta: buildErrorAgentMeta({
11711171
sessionId: activeSessionId,
1172+
sessionFile: activeSessionFile,
11721173
provider,
11731174
model: model.id,
11741175
contextTokens: ctxInfo.tokens,
@@ -1465,6 +1466,7 @@ export async function runEmbeddedPiAgent(
14651466
durationMs: Date.now() - started,
14661467
agentMeta: buildErrorAgentMeta({
14671468
sessionId: activeSessionId,
1469+
sessionFile: activeSessionFile,
14681470
provider,
14691471
model: model.id,
14701472
contextTokens: ctxInfo.tokens,
@@ -1967,6 +1969,7 @@ export async function runEmbeddedPiAgent(
19671969
durationMs: Date.now() - started,
19681970
agentMeta: buildErrorAgentMeta({
19691971
sessionId: sessionIdUsed,
1972+
sessionFile: activeSessionFile,
19701973
provider,
19711974
model: model.id,
19721975
contextTokens: ctxInfo.tokens,
@@ -1997,6 +2000,7 @@ export async function runEmbeddedPiAgent(
19972000
durationMs: Date.now() - started,
19982001
agentMeta: buildErrorAgentMeta({
19992002
sessionId: sessionIdUsed,
2003+
sessionFile: activeSessionFile,
20002004
provider,
20012005
model: model.id,
20022006
contextTokens: ctxInfo.tokens,
@@ -2068,6 +2072,7 @@ export async function runEmbeddedPiAgent(
20682072
durationMs: Date.now() - started,
20692073
agentMeta: buildErrorAgentMeta({
20702074
sessionId: sessionIdUsed,
2075+
sessionFile: activeSessionFile,
20712076
provider,
20722077
model: model.id,
20732078
contextTokens: ctxInfo.tokens,
@@ -2108,6 +2113,7 @@ export async function runEmbeddedPiAgent(
21082113
durationMs: Date.now() - started,
21092114
agentMeta: buildErrorAgentMeta({
21102115
sessionId: sessionIdUsed,
2116+
sessionFile: activeSessionFile,
21112117
provider,
21122118
model: model.id,
21132119
contextTokens: ctxInfo.tokens,

src/agents/pi-embedded-runner/run/helpers.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { AssistantMessage } from "@earendil-works/pi-ai";
22
import { describe, expect, it } from "vitest";
3-
import { resolveFinalAssistantRawText, resolveFinalAssistantVisibleText } from "./helpers.js";
3+
import {
4+
buildErrorAgentMeta,
5+
resolveFinalAssistantRawText,
6+
resolveFinalAssistantVisibleText,
7+
} from "./helpers.js";
48

59
function makeAssistantMessage(
610
content: AssistantMessage["content"],
@@ -73,3 +77,21 @@ describe("resolveFinalAssistantVisibleText", () => {
7377
expect(resolveFinalAssistantRawText(lastAssistant)).toBe("<final>keep this</final>");
7478
});
7579
});
80+
81+
describe("buildErrorAgentMeta", () => {
82+
it("preserves active session file for error exits after transcript rotation", () => {
83+
expect(
84+
buildErrorAgentMeta({
85+
sessionId: "session-rotated",
86+
sessionFile: "/tmp/session-rotated.jsonl",
87+
provider: "anthropic",
88+
model: "claude-opus-4-6",
89+
usageAccumulator: {},
90+
lastRunPromptUsage: undefined,
91+
}),
92+
).toMatchObject({
93+
sessionId: "session-rotated",
94+
sessionFile: "/tmp/session-rotated.jsonl",
95+
});
96+
});
97+
});

src/agents/pi-embedded-runner/run/helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export function buildUsageAgentMetaFields(params: {
161161
*/
162162
export function buildErrorAgentMeta(params: {
163163
sessionId: string;
164+
sessionFile?: string;
164165
provider: string;
165166
model: string;
166167
contextTokens?: number;
@@ -177,6 +178,7 @@ export function buildErrorAgentMeta(params: {
177178
});
178179
return {
179180
sessionId: params.sessionId,
181+
...(params.sessionFile ? { sessionFile: params.sessionFile } : {}),
180182
provider: params.provider,
181183
model: params.model,
182184
...(params.contextTokens ? { contextTokens: params.contextTokens } : {}),

src/cron/isolated-agent.session-identity.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import path from "node:path";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import * as modelThinkingDefault from "../agents/model-thinking-default.js";
66
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
7-
import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js";
7+
import {
8+
makeCfg,
9+
makeJob,
10+
writeSessionStore,
11+
writeSessionStoreEntries,
12+
} from "./isolated-agent.test-harness.js";
813
import {
914
DEFAULT_AGENT_TURN_PAYLOAD,
1015
DEFAULT_MESSAGE,
@@ -18,6 +23,7 @@ import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-h
1823
import {
1924
mockRunCronFallbackPassthrough,
2025
runEmbeddedPiAgentMock,
26+
updateSessionStoreMock,
2127
} from "./isolated-agent/run.test-harness.js";
2228

2329
setupRunCronIsolatedAgentTurnSuite();
@@ -142,6 +148,79 @@ describe("runCronIsolatedAgentTurn session identity", () => {
142148
});
143149
});
144150

151+
it("persists rotated transcript identity for session-bound cron runs", async () => {
152+
await withTempHome(async (home) => {
153+
const deps = makeDeps();
154+
const boundSessionKey = "agent:main:telegram:direct:42";
155+
const originalSessionFile = path.join(home, "bound-session.jsonl");
156+
const rotatedSessionFile = path.join(home, "bound-session-rotated.jsonl");
157+
const storePath = await writeSessionStoreEntries(home, {
158+
[boundSessionKey]: {
159+
sessionId: "bound-session",
160+
sessionFile: originalSessionFile,
161+
updatedAt: Date.now(),
162+
lastInteractionAt: Date.now() - 1_000,
163+
systemSent: true,
164+
},
165+
});
166+
runEmbeddedPiAgentMock.mockResolvedValueOnce({
167+
payloads: [{ text: "ok" }],
168+
meta: {
169+
durationMs: 5,
170+
agentMeta: {
171+
sessionId: "bound-session-rotated",
172+
sessionFile: rotatedSessionFile,
173+
provider: "anthropic",
174+
model: "claude-opus-4-6",
175+
compactionCount: 1,
176+
compactionTokensAfter: 42,
177+
},
178+
},
179+
});
180+
updateSessionStoreMock.mockImplementation(async (_storePath, update) => {
181+
const store = {
182+
[boundSessionKey]: {
183+
sessionId: "bound-session",
184+
sessionFile: originalSessionFile,
185+
updatedAt: Date.now(),
186+
lastInteractionAt: Date.now() - 1_000,
187+
systemSent: true,
188+
},
189+
};
190+
update(store);
191+
});
192+
193+
const res = await runCronIsolatedAgentTurn({
194+
cfg: makeCfg(home, storePath),
195+
deps,
196+
job: {
197+
...makeJob(DEFAULT_AGENT_TURN_PAYLOAD),
198+
sessionTarget: `session:${boundSessionKey}`,
199+
delivery: { mode: "none" },
200+
},
201+
message: DEFAULT_MESSAGE,
202+
sessionKey: boundSessionKey,
203+
lane: "cron",
204+
});
205+
206+
expect(res.status).toBe("ok");
207+
expect(res.sessionId).toBe("bound-session-rotated");
208+
209+
const finalPersist = updateSessionStoreMock.mock.calls.at(-1);
210+
expect(finalPersist?.[0]).toBe(storePath);
211+
const persistedStore: Record<string, { [key: string]: unknown }> = {};
212+
(finalPersist?.[1] as (store: typeof persistedStore) => void)(persistedStore);
213+
expect(persistedStore[boundSessionKey]).toEqual(
214+
expect.objectContaining({
215+
sessionId: "bound-session-rotated",
216+
sessionFile: rotatedSessionFile,
217+
usageFamilyKey: boundSessionKey,
218+
usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
219+
}),
220+
);
221+
});
222+
});
223+
145224
it("uses lightweight bootstrap context for command-style cron payloads", async () => {
146225
await withTempHome(async (home) => {
147226
await runCronTurn(home, {

src/cron/isolated-agent/run-session-state.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import os from "node:os";
44
import path from "node:path";
55
import { describe, expect, it, vi } from "vitest";
66
import type { SessionEntry } from "../../config/sessions.js";
7-
import { createPersistCronSessionEntry, type MutableCronSession } from "./run-session-state.js";
7+
import {
8+
adoptCronRunSessionMetadata,
9+
createPersistCronSessionEntry,
10+
type MutableCronSession,
11+
} from "./run-session-state.js";
812

913
function makeSessionEntry(overrides?: Partial<SessionEntry>): SessionEntry {
1014
return {
@@ -159,6 +163,56 @@ describe("createPersistCronSessionEntry", () => {
159163

160164
expect(cronSession.store["agent:main:session"]).toBe(cronSession.sessionEntry);
161165
});
166+
167+
it("adopts rotated run transcript metadata before persisting session-bound cron state", async () => {
168+
const cronSession = makeCronSession(
169+
makeSessionEntry({
170+
sessionId: "bound-session",
171+
sessionFile: "/tmp/bound-session.jsonl",
172+
}),
173+
);
174+
const changed = adoptCronRunSessionMetadata({
175+
entry: cronSession.sessionEntry,
176+
sessionKey: "agent:main:telegram:direct:42",
177+
runMeta: {
178+
sessionId: "bound-session-rotated",
179+
sessionFile: "/tmp/bound-session-rotated.jsonl",
180+
},
181+
});
182+
const updateSessionStore = vi.fn(
183+
async (_storePath, update: (store: Record<string, SessionEntry>) => void) => {
184+
const store: Record<string, SessionEntry> = {};
185+
update(store);
186+
expect(store["agent:main:telegram:direct:42"]).toEqual({
187+
sessionId: "bound-session-rotated",
188+
sessionFile: "/tmp/bound-session-rotated.jsonl",
189+
usageFamilyKey: "agent:main:telegram:direct:42",
190+
usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
191+
updatedAt: 1000,
192+
systemSent: true,
193+
});
194+
},
195+
);
196+
197+
expect(changed).toBe(true);
198+
const persist = createPersistCronSessionEntry({
199+
isFastTestEnv: false,
200+
cronSession,
201+
agentSessionKey: "agent:main:telegram:direct:42",
202+
updateSessionStore,
203+
});
204+
205+
await persist();
206+
207+
expect(cronSession.store["agent:main:telegram:direct:42"]).toEqual({
208+
sessionId: "bound-session-rotated",
209+
sessionFile: "/tmp/bound-session-rotated.jsonl",
210+
usageFamilyKey: "agent:main:telegram:direct:42",
211+
usageFamilySessionIds: ["bound-session", "bound-session-rotated"],
212+
updatedAt: 1000,
213+
systemSent: true,
214+
});
215+
});
162216
});
163217

164218
async function createTranscriptFile(): Promise<string> {

src/cron/isolated-agent/run-session-state.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ function cronTranscriptExists(entry: SessionEntry): boolean {
2626
return Boolean(sessionFile && fs.existsSync(sessionFile));
2727
}
2828

29+
function normalizeSessionField(value: string | undefined): string | undefined {
30+
const trimmed = value?.trim();
31+
return trimmed ? trimmed : undefined;
32+
}
33+
2934
function toNonResumableCronSessionEntry(entry: SessionEntry): SessionEntry {
3035
const next = { ...entry } as Partial<SessionEntry>;
3136
delete next.sessionId;
@@ -61,6 +66,43 @@ export function createPersistCronSessionEntry(params: {
6166
};
6267
}
6368

69+
export function adoptCronRunSessionMetadata(params: {
70+
entry: MutableCronSessionEntry;
71+
sessionKey: string;
72+
runMeta?: {
73+
sessionId?: string;
74+
sessionFile?: string;
75+
};
76+
}): boolean {
77+
const nextSessionId = normalizeSessionField(params.runMeta?.sessionId);
78+
const nextSessionFile = normalizeSessionField(params.runMeta?.sessionFile);
79+
if (!nextSessionFile) {
80+
return false;
81+
}
82+
83+
let changed = false;
84+
const previousSessionId = params.entry.sessionId;
85+
if (nextSessionId && nextSessionId !== previousSessionId) {
86+
params.entry.sessionId = nextSessionId;
87+
params.entry.usageFamilyKey = params.entry.usageFamilyKey ?? params.sessionKey;
88+
params.entry.usageFamilySessionIds = Array.from(
89+
new Set([
90+
...(params.entry.usageFamilySessionIds ?? []),
91+
...(previousSessionId ? [previousSessionId] : []),
92+
nextSessionId,
93+
]),
94+
);
95+
changed = true;
96+
}
97+
98+
if (nextSessionFile !== params.entry.sessionFile) {
99+
params.entry.sessionFile = nextSessionFile;
100+
changed = true;
101+
}
102+
103+
return changed;
104+
}
105+
64106
export async function persistCronSkillsSnapshotIfChanged(params: {
65107
isFastTestEnv: boolean;
66108
cronSession: MutableCronSession;

src/cron/isolated-agent/run.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import { resolveCronModelSelection } from "./model-selection.js";
3838
import { buildCronAgentDefaultsConfig } from "./run-config.js";
3939
import {
40+
adoptCronRunSessionMetadata,
4041
createPersistCronSessionEntry,
4142
markCronSessionPreRun,
4243
persistCronSkillsSnapshotIfChanged,
@@ -580,7 +581,7 @@ async function prepareCronRunContext(params: {
580581
});
581582
const withRunSession: WithRunSession = (result) => ({
582583
...result,
583-
sessionId: runSessionId,
584+
sessionId: cronSession.sessionEntry.sessionId ?? runSessionId,
584585
sessionKey: runSessionKey,
585586
});
586587
if (!cronSession.sessionEntry.label?.trim() && baseSessionKey.startsWith("cron:")) {
@@ -852,6 +853,11 @@ async function finalizeCronRun(params: {
852853
if (finalRunResult.meta?.systemPromptReport) {
853854
prepared.cronSession.sessionEntry.systemPromptReport = finalRunResult.meta.systemPromptReport;
854855
}
856+
adoptCronRunSessionMetadata({
857+
entry: prepared.cronSession.sessionEntry,
858+
sessionKey: prepared.agentSessionKey,
859+
runMeta: finalRunResult.meta?.agentMeta,
860+
});
855861
const usage = finalRunResult.meta?.agentMeta?.usage;
856862
const promptTokens = finalRunResult.meta?.agentMeta?.promptTokens;
857863
const modelUsed =

0 commit comments

Comments
 (0)