Skip to content

Commit 111d11c

Browse files
committed
fix(cron): preflight implicit announce targets
Signed-off-by: sallyom <[email protected]>
1 parent 372e270 commit 111d11c

3 files changed

Lines changed: 104 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai
128128
- Cron/doctor: repair persisted cron jobs whose `payload.model` was stored as `"default"`, `"null"`, blank, or JSON `null` by removing the bad override during `openclaw doctor --fix` while keeping cron runtime model validation strict. Fixes #78549. Thanks @bizzle12368239.
129129
- Telegram: honor `accessGroup:*` sender allowlists for DMs, groups, native commands, and callback authorization before applying Telegram's numeric sender-ID checks. Fixes #78660. Thanks @manugc.
130130
- Agent delivery: report `deliverySucceeded=false` when outbound delivery returns no adapter result, so claimed/empty delivery paths no longer masquerade as successful sends. Fixes #78532. Thanks @joeyfrasier.
131+
- Cron/isolated runs: fail implicit announce delivery before model execution when `delivery.channel=last` has no previous route, so recurring jobs do not spend tokens before hitting a permanent delivery-target error. Fixes #78608. Thanks @sallyom.
131132
- Doctor/OpenAI Codex: revert the 2026.5.5 `doctor --fix` repair that rewrote valid `openai-codex/*` ChatGPT/Codex OAuth routes to `openai/*`, which could break OAuth-only GPT-5.5 setups or accidentally move users onto the OpenAI API-key route. If 2026.5.5 already changed your default model, run `openclaw models set openai-codex/gpt-5.5 && openclaw config validate` to switch the default agent back to the Codex OAuth PI route. Fixes #78407.
132133
- Discord/groups: instruct group-chat agents to stay silent when a message is addressed to someone else, replying only when invited or correcting key facts. (#78615)
133134
- Discord/groups: tell Discord-channel agents to wrap bare URLs as `<https://example.com>` so link previews do not expand into uninvited embeds. (#78614)

src/cron/isolated-agent/run.message-tool-policy.test.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import type { SkillSnapshot } from "../../agents/skills.js";
33
import type { CronDeliveryMode } from "../types.js";
44
import type { MutableCronSession } from "./run-session-state.js";
@@ -695,16 +695,17 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
695695
resolveCronDeliveryPlanMock.mockReturnValue({
696696
requested: true,
697697
mode: "announce",
698-
channel: "last",
698+
channel: "messagechat",
699+
to: "bad-target",
699700
});
700701
resolveDeliveryTargetMock.mockResolvedValue({
701702
ok: false,
702-
channel: undefined,
703+
channel: "messagechat",
703704
to: undefined,
704705
accountId: undefined,
705706
threadId: undefined,
706-
mode: "implicit",
707-
error: new Error("sessionKey is required to resolve delivery.channel=last"),
707+
mode: "explicit",
708+
error: new Error("Invalid delivery target: unknown recipient"),
708709
});
709710
runEmbeddedPiAgentMock.mockResolvedValue(
710711
makeMessageToolRunResult([{ tool: "message", provider: "messagechat", to: "123" }]),
@@ -720,15 +721,64 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
720721
unverifiedMessagingToolDelivery: true,
721722
}),
722723
);
724+
expect(result.delivery).toEqual(
725+
expect.objectContaining({
726+
intended: { channel: "messagechat", to: "bad-target", source: "explicit" },
727+
resolved: expect.objectContaining({
728+
ok: false,
729+
channel: "messagechat",
730+
source: "explicit",
731+
error: "Invalid delivery target: unknown recipient",
732+
}),
733+
messageToolSentTo: [{ channel: "messagechat", to: "123" }],
734+
fallbackUsed: false,
735+
delivered: false,
736+
}),
737+
);
738+
});
739+
740+
it("fails implicit announce delivery before running the agent when last has no route", async () => {
741+
const onExecutionStarted = vi.fn();
742+
resolveCronDeliveryPlanMock.mockReturnValue({
743+
requested: true,
744+
mode: "announce",
745+
channel: "last",
746+
});
747+
resolveDeliveryTargetMock.mockResolvedValue({
748+
ok: false,
749+
channel: undefined,
750+
to: undefined,
751+
accountId: undefined,
752+
threadId: undefined,
753+
mode: "implicit",
754+
error: new Error("Channel is required when delivery.channel=last has no previous channel."),
755+
});
756+
757+
const result = await runCronIsolatedAgentTurn({
758+
...makeParams(),
759+
onExecutionStarted,
760+
});
761+
762+
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
763+
expect(dispatchCronDeliveryMock).not.toHaveBeenCalled();
764+
expect(onExecutionStarted).not.toHaveBeenCalled();
765+
expect(result).toEqual(
766+
expect.objectContaining({
767+
status: "error",
768+
error: "Channel is required when delivery.channel=last has no previous channel.",
769+
errorKind: "delivery-target",
770+
delivered: false,
771+
deliveryAttempted: false,
772+
}),
773+
);
723774
expect(result.delivery).toEqual(
724775
expect.objectContaining({
725776
intended: { channel: "last", to: null, source: "last" },
726777
resolved: expect.objectContaining({
727778
ok: false,
728779
source: "last",
729-
error: "sessionKey is required to resolve delivery.channel=last",
780+
error: "Channel is required when delivery.channel=last has no previous channel.",
730781
}),
731-
messageToolSentTo: [{ channel: "messagechat", to: "123" }],
732782
fallbackUsed: false,
733783
delivered: false,
734784
}),

src/cron/isolated-agent/run.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,43 @@ function hasExplicitCronDeliveryTarget(plan: CronDeliveryPlan): boolean {
325325
);
326326
}
327327

328+
function shouldFailImplicitAnnounceDeliveryBeforeExecution(params: {
329+
deliveryPlan: CronDeliveryPlan;
330+
deliveryRequested: boolean;
331+
resolvedDelivery: ResolvedCronDeliveryTarget;
332+
}): boolean {
333+
return (
334+
params.deliveryRequested &&
335+
params.deliveryPlan.mode === "announce" &&
336+
!hasExplicitCronDeliveryTarget(params.deliveryPlan) &&
337+
!params.resolvedDelivery.ok
338+
);
339+
}
340+
341+
function failImplicitAnnounceDeliveryBeforeExecution(
342+
prepared: PreparedCronRunContext,
343+
): RunCronAgentTurnResult {
344+
const error = prepared.resolvedDelivery.ok
345+
? "Cron announce delivery target is unresolved."
346+
: prepared.resolvedDelivery.error.message;
347+
return prepared.withRunSession({
348+
status: "error",
349+
error,
350+
errorKind: "delivery-target",
351+
delivered: false,
352+
deliveryAttempted: false,
353+
delivery: buildCronDeliveryTrace({
354+
deliveryPlan: prepared.deliveryPlan,
355+
resolvedDelivery: prepared.resolvedDelivery,
356+
messagingToolSentTargets: [],
357+
matchesMessagingToolDeliveryTarget: () => false,
358+
fallbackUsed: false,
359+
delivered: false,
360+
}),
361+
diagnostics: createCronRunDiagnosticsFromError("delivery", error),
362+
});
363+
}
364+
328365
async function resolveCronDeliveryContext(params: {
329366
cfg: OpenClawConfig;
330367
job: CronJob;
@@ -1065,6 +1102,15 @@ export async function runCronIsolatedAgentTurn(params: {
10651102
if (!prepared.ok) {
10661103
return prepared.result;
10671104
}
1105+
if (
1106+
shouldFailImplicitAnnounceDeliveryBeforeExecution({
1107+
deliveryPlan: prepared.context.deliveryPlan,
1108+
deliveryRequested: prepared.context.deliveryRequested,
1109+
resolvedDelivery: prepared.context.resolvedDelivery,
1110+
})
1111+
) {
1112+
return failImplicitAnnounceDeliveryBeforeExecution(prepared.context);
1113+
}
10681114
const notifyExecutionStarted = () =>
10691115
params.onExecutionStarted?.({
10701116
jobId: params.job.id,

0 commit comments

Comments
 (0)