fix(cron-cli): bound loadCronJobForShow pagination (#83856)

This commit is contained in:
clawsweeper
2026-05-20 07:02:31 +00:00
parent 0c67dc7f82
commit 7828b4bdae
3 changed files with 82 additions and 6 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987.
- Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987.
- CLI/cron: bound `openclaw cron show` job lookup pagination so non-advancing or unbounded `cron.list` responses fail instead of hanging the command. Fixes #83856. (#83989)
- Agents/messages: stop message-tool-only turns after a successful source-channel `message` send while keeping transcript mirrors under the session write lock. (#84289)
- Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.
- Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving `strict=false` fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.

View File

@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CronJob } from "../../cron/types.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
const callGatewayFromCli = vi.fn();
vi.mock("../gateway-rpc.js", async () => {
const actual = await vi.importActual<typeof import("../gateway-rpc.js")>("../gateway-rpc.js");
return {
...actual,
callGatewayFromCli: (...args: Parameters<typeof actual.callGatewayFromCli>) =>
callGatewayFromCli(...args),
};
});
const { loadCronJobForShow } = await import("./register.cron-simple.js");
const opts: GatewayRpcOpts = {} as GatewayRpcOpts;
describe("loadCronJobForShow pagination guard (regression for #83856)", () => {
beforeEach(() => {
callGatewayFromCli.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("throws when nextOffset fails to advance", async () => {
callGatewayFromCli.mockResolvedValue({
jobs: [],
hasMore: true,
nextOffset: 0,
});
await expect(loadCronJobForShow(opts, "missing")).rejects.toThrow(/pagination did not advance/);
expect(callGatewayFromCli).toHaveBeenCalledTimes(1);
});
it("throws when pagination exceeds the max page count", async () => {
let nextOffset = 0;
callGatewayFromCli.mockImplementation(async () => {
nextOffset += 1;
return { jobs: [], hasMore: true, nextOffset };
});
await expect(loadCronJobForShow(opts, "missing")).rejects.toThrow(
/pagination exceeded maximum pages/,
);
expect(callGatewayFromCli.mock.calls.length).toBeGreaterThan(1);
expect(callGatewayFromCli.mock.calls.length).toBeLessThanOrEqual(50);
});
it("returns the job when found on a later page", async () => {
const job: CronJob = { id: "abc", name: "wanted" } as unknown as CronJob;
callGatewayFromCli
.mockResolvedValueOnce({ jobs: [], hasMore: true, nextOffset: 200 })
.mockResolvedValueOnce({ jobs: [job], hasMore: false, nextOffset: null });
const result = await loadCronJobForShow(opts, "wanted");
expect(result.job?.id).toBe("abc");
expect(callGatewayFromCli).toHaveBeenCalledTimes(2);
});
it("returns empty result when pagination terminates without a match", async () => {
callGatewayFromCli.mockResolvedValueOnce({
jobs: [],
hasMore: false,
nextOffset: null,
});
const result = await loadCronJobForShow(opts, "missing");
expect(result.job).toBeUndefined();
});
});

View File

@@ -15,6 +15,7 @@ import {
} from "./shared.js";
const CRON_SHOW_PAGE_SIZE = 200;
const CRON_SHOW_LOOKUP_MAX_PAGES = 50;
const CRON_RUN_WAIT_TIMEOUT_DEFAULT = "10m";
const CRON_RUN_WAIT_POLL_INTERVAL_DEFAULT = "2s";
@@ -87,32 +88,36 @@ function findCronJobInPage(jobs: CronJob[], idOrName: string): CronJob | undefin
);
}
async function loadCronJobForShow(
export async function loadCronJobForShow(
opts: GatewayRpcOpts,
idOrName: string,
): Promise<{ job?: CronJob; deliveryPreview?: CronDeliveryPreview }> {
let offset = 0;
for (;;) {
for (let page = 0; page < CRON_SHOW_LOOKUP_MAX_PAGES; page += 1) {
const res = await callGatewayFromCli("cron.list", opts, {
includeDisabled: true,
limit: CRON_SHOW_PAGE_SIZE,
offset,
});
const page = res as {
const listed = res as {
jobs?: CronJob[];
hasMore?: boolean;
nextOffset?: number | null;
};
const jobs = page.jobs ?? [];
const jobs = listed.jobs ?? [];
const job = findCronJobInPage(jobs, idOrName);
if (job) {
return { job, deliveryPreview: coerceCronDeliveryPreviews(res).get(job.id) };
}
if (!page.hasMore || typeof page.nextOffset !== "number") {
if (!listed.hasMore || typeof listed.nextOffset !== "number") {
return {};
}
offset = page.nextOffset;
if (listed.nextOffset <= offset) {
throw new Error("cron.list pagination did not advance while looking up cron job");
}
offset = listed.nextOffset;
}
throw new Error("cron.list pagination exceeded maximum pages while looking up cron job");
}
function registerCronToggleCommand(params: {