fix(cron): preserve isolated agent turn payload message (#91230)

Summary:
- The PR changes isolated cron agent prompt construction to read agentTurn text from `job.payload.message` and adds regression coverage for malformed dispatch messages plus SQLite-rehydrated manual runs.
- PR surface: Source +8, Tests +60. Total +68 across 3 files.
- Reproducibility: yes. source-level: current main interpolates `input.message` into the isolated cron prompt, ... release report supplies operator repro evidence; I did not run it locally because this review is read-only.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(cron): preserve isolated agent turn payload message

Validation:
- ClawSweeper review passed for head 4d33607efd.
- Required merge gates passed before the squash merge.

Prepared head SHA: 4d33607efd
Review: https://github.com/openclaw/openclaw/pull/91230#issuecomment-4643779241

Co-authored-by: 宇宙熊Yzx <53250620+849261680@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
Yzx
2026-06-08 10:23:02 +08:00
committed by GitHub
parent 766c5b3d32
commit 4780546c12
3 changed files with 71 additions and 3 deletions

View File

@@ -39,6 +39,31 @@ function requireModelFallbackRequest(): {
describe("runCronIsolatedAgentTurn — payload.fallbacks", () => {
setupRunCronIsolatedAgentTurnSuite({ fast: true });
it("uses the persisted agentTurn payload message when the dispatch message is malformed", async () => {
mockRunCronFallbackPassthrough();
const dispatchMessage = "SERIALIZATION_PROBE should not be wrapped";
const result = await runCronIsolatedAgentTurn(
makeIsolatedAgentTurnParams({
job: makeIsolatedAgentTurnJob({
payload: {
kind: "agentTurn",
message:
"SERIALIZATION_PROBE: reply exactly with the marker token you received and nothing else.",
},
}),
message: { message: dispatchMessage } as unknown as string,
}),
);
expect(result.status).toBe("ok");
expect(runEmbeddedAgentMock).toHaveBeenCalledOnce();
const request = runEmbeddedAgentMock.mock.calls[0]?.[0] as { prompt?: unknown } | undefined;
expect(request?.prompt).toContain("SERIALIZATION_PROBE: reply exactly");
expect(request?.prompt).not.toContain(dispatchMessage);
expect(request?.prompt).not.toContain("[object Object]");
});
it.each([
{
name: "passes payload.fallbacks as fallbacksOverride when defined",

View File

@@ -456,6 +456,13 @@ type RunCronAgentTurnParams = {
lane?: string;
};
function resolveCronAgentTurnMessage(input: RunCronAgentTurnParams): string {
if (input.job.payload.kind === "agentTurn") {
return input.job.payload.message;
}
return input.message;
}
type WithRunSession = (
result: Omit<RunCronAgentTurnResult, "sessionId" | "sessionKey">,
) => RunCronAgentTurnResult;
@@ -765,7 +772,8 @@ async function prepareCronRunContext(params: {
});
const { formattedTime, timeLine } = resolveCronStyleNow(input.cfg, now);
const base = `[cron:${input.job.id} ${input.job.name}] ${input.message}`.trim();
const message = resolveCronAgentTurnMessage(input);
const base = `[cron:${input.job.id} ${input.job.name}] ${message}`.trim();
const isExternalHook =
hookExternalContentSource !== undefined || isExternalHookSession(baseSessionKey);
const allowUnsafeExternalContent =
@@ -776,7 +784,7 @@ async function prepareCronRunContext(params: {
if (isExternalHook) {
const { detectSuspiciousPatterns } = await loadCronExternalContentRuntime();
const suspiciousPatterns = detectSuspiciousPatterns(input.message);
const suspiciousPatterns = detectSuspiciousPatterns(message);
if (suspiciousPatterns.length > 0) {
logWarn(
`[security] Suspicious patterns detected in external hook content ` +
@@ -789,7 +797,7 @@ async function prepareCronRunContext(params: {
const { buildSafeExternalPrompt } = await loadCronExternalContentRuntime();
const hookType = mapHookExternalContentSource(hookExternalContentSource ?? "webhook");
const safeContent = buildSafeExternalPrompt({
content: input.message,
content: message,
source: hookType,
jobName: input.job.name,
jobId: input.job.id,

View File

@@ -264,6 +264,41 @@ describe("cron service ops regressions", () => {
expect((staleExecuted?.state.nextRunAtMs ?? 0) > nowMs).toBe(true);
});
it("passes the rehydrated agentTurn payload message to isolated manual runs", async () => {
const store = opsRegressionFixtures.makeStorePath();
const nowMs = Date.now();
const marker =
"SERIALIZATION_PROBE: reply exactly with the marker token you received and nothing else.";
const job = createIsolatedRegressionJob({
id: "manual-payload-message",
name: "manual payload message",
scheduledAt: nowMs,
schedule: { kind: "at", at: new Date(nowMs + 3_600_000).toISOString() },
payload: { kind: "agentTurn", message: marker },
state: { nextRunAtMs: nowMs + 3_600_000 },
});
await saveCronStore(store.storePath, { version: 1, jobs: [job] });
const runIsolatedAgentJob = vi.fn().mockResolvedValue({ status: "ok", summary: "ok" });
const state = createCronServiceState({
cronEnabled: false,
storePath: store.storePath,
log: noopLogger,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
runIsolatedAgentJob,
});
const runResult = await run(state, job.id, "force");
expect(runResult).toEqual({ ok: true, ran: true });
expect(runIsolatedAgentJob).toHaveBeenCalledOnce();
const [params] = requireMockCall(runIsolatedAgentJob, 0, "runIsolatedAgentJob") as [
{ message?: unknown }?,
];
expect(params?.message).toBe(marker);
});
it("applies timeoutSeconds to manual cron.run isolated executions", async () => {
vi.useFakeTimers();
try {