Skip to content

Commit eba6637

Browse files
fix(cron): persist resolved delivery target into isolated cron session deliveryContext (#92460)
1 parent 80f1ae6 commit eba6637

4 files changed

Lines changed: 135 additions & 0 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Standalone reproduction for issue #92460.
2+
//
3+
// Verifies that a resolved cron delivery target is written back to the isolated
4+
// cron session entry's deliveryContext, so completion-announce paths can recover
5+
// an explicit delivery.channel without falling back to a channel-less main session.
6+
7+
import type { SessionEntry } from "../../src/config/sessions.js";
8+
import { setCronSessionDeliveryContextFromResolvedDelivery } from "../../src/cron/isolated-agent/run-session-state.js";
9+
10+
const entry: SessionEntry = {
11+
sessionId: "repro-run",
12+
updatedAt: Date.now(),
13+
systemSent: true,
14+
};
15+
16+
setCronSessionDeliveryContextFromResolvedDelivery(entry, {
17+
ok: true,
18+
channel: "webchat",
19+
to: "controller",
20+
accountId: "default",
21+
threadId: "thread-42",
22+
mode: "explicit",
23+
});
24+
25+
console.log("=== Reproduction for issue #92460 ===");
26+
console.log(
27+
"Isolated cron session deliveryContext:",
28+
JSON.stringify(entry.deliveryContext, null, 2),
29+
);
30+
31+
const ok =
32+
entry.deliveryContext?.channel === "webchat" &&
33+
entry.deliveryContext?.to === "controller" &&
34+
entry.deliveryContext?.accountId === "default" &&
35+
entry.deliveryContext?.threadId === "thread-42";
36+
37+
if (ok) {
38+
console.log("PASS: explicit delivery.channel survives to the session entry deliveryContext");
39+
process.exitCode = 0;
40+
} else {
41+
console.error(
42+
"FAIL: expected deliveryContext { channel: webchat, to: controller, accountId: default, threadId: thread-42 }",
43+
);
44+
process.exitCode = 1;
45+
}

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { SessionEntry } from "../../config/sessions.js";
88
import {
99
adoptCronRunSessionMetadata,
1010
createPersistCronSessionEntry,
11+
setCronSessionDeliveryContextFromResolvedDelivery,
1112
type MutableCronSession,
1213
} from "./run-session-state.js";
1314

@@ -216,6 +217,70 @@ describe("createPersistCronSessionEntry", () => {
216217
});
217218
});
218219

220+
describe("setCronSessionDeliveryContextFromResolvedDelivery", () => {
221+
it("persists a resolved explicit delivery target on the session entry", () => {
222+
const cronSession = makeCronSession();
223+
setCronSessionDeliveryContextFromResolvedDelivery(cronSession.sessionEntry, {
224+
ok: true,
225+
channel: "webchat",
226+
227+
accountId: "account-1",
228+
threadId: "thread-1",
229+
mode: "explicit",
230+
});
231+
expect(cronSession.sessionEntry.deliveryContext).toEqual({
232+
channel: "webchat",
233+
234+
accountId: "account-1",
235+
threadId: "thread-1",
236+
});
237+
});
238+
239+
it("persists the resolved target without optional account/thread fields", () => {
240+
const cronSession = makeCronSession();
241+
setCronSessionDeliveryContextFromResolvedDelivery(cronSession.sessionEntry, {
242+
ok: true,
243+
channel: "webchat",
244+
245+
mode: "explicit",
246+
});
247+
expect(cronSession.sessionEntry.deliveryContext).toEqual({
248+
channel: "webchat",
249+
250+
});
251+
});
252+
253+
it("leaves deliveryContext unchanged when target resolution fails", () => {
254+
const cronSession = makeCronSession(
255+
makeSessionEntry({ deliveryContext: { channel: "webchat", to: "stale" } }),
256+
);
257+
setCronSessionDeliveryContextFromResolvedDelivery(cronSession.sessionEntry, {
258+
ok: false,
259+
channel: undefined,
260+
to: undefined,
261+
accountId: undefined,
262+
threadId: undefined,
263+
mode: "implicit",
264+
error: new Error("Channel is required"),
265+
});
266+
expect(cronSession.sessionEntry.deliveryContext).toEqual({
267+
channel: "webchat",
268+
to: "stale",
269+
});
270+
});
271+
272+
it("does not create an empty deliveryContext when the resolved target has no routable channel", () => {
273+
const cronSession = makeCronSession();
274+
setCronSessionDeliveryContextFromResolvedDelivery(cronSession.sessionEntry, {
275+
ok: true,
276+
channel: "webchat",
277+
to: "",
278+
mode: "explicit",
279+
});
280+
expect(cronSession.sessionEntry.deliveryContext).toBeUndefined();
281+
});
282+
});
283+
219284
async function createTranscriptFile(): Promise<string> {
220285
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-session-"));
221286
const file = path.join(dir, "session.jsonl");

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { LiveSessionModelSelection } from "../../agents/live-model-switch.j
44
import type { SessionEntry } from "../../config/sessions.js";
55
import { isCronSessionKey } from "../../sessions/session-key-utils.js";
66
import type { SkillSnapshot } from "../../skills/types.js";
7+
import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js";
8+
import type { DeliveryTargetResolution } from "./delivery-target.js";
79
import type { resolveCronSession } from "./session.js";
810

911
type MutableSessionStore = Record<string, SessionEntry>;
@@ -114,6 +116,27 @@ export function adoptCronRunSessionMetadata(params: {
114116
return changed;
115117
}
116118

119+
/** Writes a resolved cron delivery target into the isolated session entry so later
120+
* completion-announce paths can recover the explicit channel/account/thread without
121+
* re-resolving from an empty main session. */
122+
export function setCronSessionDeliveryContextFromResolvedDelivery(
123+
entry: MutableCronSessionEntry,
124+
resolvedDelivery: DeliveryTargetResolution,
125+
): void {
126+
if (!resolvedDelivery.ok) {
127+
return;
128+
}
129+
const context = normalizeDeliveryContext({
130+
channel: resolvedDelivery.channel,
131+
to: resolvedDelivery.to,
132+
accountId: resolvedDelivery.accountId,
133+
threadId: resolvedDelivery.threadId,
134+
});
135+
if (context?.to) {
136+
entry.deliveryContext = context;
137+
}
138+
}
139+
117140
/** Persists a changed skills snapshot onto the cron session entry outside fast tests. */
118141
export async function persistCronSkillsSnapshotIfChanged(params: {
119142
isFastTestEnv: boolean;

src/cron/isolated-agent/run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
createPersistCronSessionEntry,
6464
markCronSessionPreRun,
6565
persistCronSkillsSnapshotIfChanged,
66+
setCronSessionDeliveryContextFromResolvedDelivery,
6667
type CronLiveSelection,
6768
type MutableCronSession,
6869
type PersistCronSessionEntry,
@@ -780,6 +781,7 @@ async function prepareCronRunContext(params: {
780781
job: input.job,
781782
agentId,
782783
});
784+
setCronSessionDeliveryContextFromResolvedDelivery(cronSession.sessionEntry, resolvedDelivery);
783785

784786
const { formattedTime, timeLine } = resolveCronStyleNow(input.cfg, now);
785787
const message = resolveCronAgentTurnMessage(input);

0 commit comments

Comments
 (0)