refactor(cron): trim internal export surface (#107640)

* refactor(cron): trim internal export surface

* chore(deadcode): refresh cron export baseline
This commit is contained in:
Peter Steinberger
2026-07-14 09:02:18 -07:00
committed by GitHub
parent 8251976cf6
commit b146ef8a59
33 changed files with 181 additions and 413 deletions
-20
View File
@@ -514,37 +514,17 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"src/crestodian/agent-turn.ts: CrestodianAgentTurnDeps",
"src/crestodian/agent-turn.ts: CrestodianAgentTurnDirective",
"src/crestodian/agent-turn.ts: runCrestodianAgentTurnWithDeps",
"src/cron/command-output-summary.ts: cronCommandSummaryNeedsExternalRedaction",
"src/cron/delivery-context.ts: cronDeliveryFromContext",
"src/cron/delivery-preview.ts: resolveCronDeliveryPreview",
"src/cron/isolated-agent.ts: RunCronAgentTurnResult",
"src/cron/isolated-agent/delivery-dispatch.ts: getCompletedDirectCronDeliveriesCountForTests",
"src/cron/isolated-agent/delivery-dispatch.ts: resetCompletedDirectCronDeliveriesForTests",
"src/cron/isolated-agent/helpers.ts: pickDeliverablePayloads",
"src/cron/isolated-agent/helpers.ts: pickLastDeliverablePayload",
"src/cron/isolated-agent/helpers.ts: pickSummaryFromPayloads",
"src/cron/isolated-agent/run-executor.ts: createCronPromptExecutor",
"src/cron/isolated-agent/run.ts: resolveCronDeliveryContext",
"src/cron/isolated-agent/run.ts: RunCronAgentTurnResult",
"src/cron/job-session-bindings.ts: CronJobSessionBinding",
"src/cron/run-diagnostics.ts: MISSING_WEB_SEARCH_PROVIDER_DIAGNOSTIC_MESSAGE",
"src/cron/schedule.ts: clearCronScheduleCacheForTest",
"src/cron/schedule.ts: getCronScheduleCacheMaxForTest",
"src/cron/schedule.ts: getCronScheduleCacheSizeForTest",
"src/cron/schedule.ts: hasCronInCacheForTest",
"src/cron/service/active-run-cancellation.ts: cancelActiveCronTaskRun",
"src/cron/service/active-run-cancellation.ts: CRON_TASK_RUN_SETTLEMENT_TRACKING_MAX_MS",
"src/cron/service/active-run-cancellation.ts: resetActiveCronTaskRunsForTests",
"src/cron/service/timeout-policy.ts: AGENT_TURN_SAFETY_TIMEOUT_MS",
"src/cron/service/timeout-policy.ts: DEFAULT_JOB_TIMEOUT_MS",
"src/cron/service/timer.ts: executeJobCore",
"src/cron/service/timer.ts: onTimer",
"src/cron/session-reaper.ts: resetReaperThrottle",
"src/cron/session-reaper.ts: resolveRetentionMs",
"src/cron/stagger.ts: isRecurringTopOfHourCronExpr",
"src/cron/store/row-codec.ts: bindScheduleColumns",
"src/cron/store/row-codec.ts: scheduleFromRow",
"src/cron/task-run-history.ts: ReadCronTaskRunHistoryPageOptions",
"src/entry.compile-cache.ts: buildOpenClawCompileCacheRespawnPlan",
"src/entry.compile-cache.ts: isNodeVersionAffectedByCompileCacheDeadlock",
"src/entry.compile-cache.ts: isSourceCheckoutInstallRoot",
-2
View File
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildCronCommandSummary,
cronCommandSummaryNeedsExternalRedaction,
redactCronCommandSummaryForExternalDelivery,
} from "./command-output-summary.js";
@@ -22,7 +21,6 @@ describe("cron command output summaries", () => {
const summary =
"action-required output preserved:\nVisit https://example.com/device or www.example.com/device and enter code ABCD-EFGH\n\ncompleted";
expect(cronCommandSummaryNeedsExternalRedaction(summary)).toBe(true);
expect(redactCronCommandSummaryForExternalDelivery(summary)).toBe(
"action-required output preserved:\nVisit [redacted-url] or [redacted-url] and enter code [redacted-code]\n\ncompleted",
);
+1 -1
View File
@@ -76,7 +76,7 @@ export function buildCronCommandSummary(params: {
return tail ? `${actionBlock}\n\n${tail}` : actionBlock;
}
export function cronCommandSummaryNeedsExternalRedaction(summary: string | undefined): boolean {
function cronCommandSummaryNeedsExternalRedaction(summary: string | undefined): boolean {
if (!summary) {
return false;
}
+15 -7
View File
@@ -10,7 +10,7 @@ vi.mock("../config/sessions/delivery-info.js", () => ({
extractDeliveryInfo: extractDeliveryInfoMock,
}));
import { cronDeliveryFromContext, resolveCronCreationDelivery } from "./delivery-context.js";
import { resolveCronCreationDelivery } from "./delivery-context.js";
describe("cron delivery context", () => {
const cfg = {} as OpenClawConfig;
@@ -22,11 +22,14 @@ describe("cron delivery context", () => {
it("builds announce delivery from deliveryContext without changing target casing", () => {
expect(
cronDeliveryFromContext({
channel: " Matrix ",
to: " !AbCdEf1234567890:Example.Org ",
accountId: " Bot-A ",
threadId: " $RootEvent:Example.Org ",
resolveCronCreationDelivery({
cfg,
currentDeliveryContext: {
channel: " Matrix ",
to: " !AbCdEf1234567890:Example.Org ",
accountId: " Bot-A ",
threadId: " $RootEvent:Example.Org ",
},
}),
).toEqual({
mode: "announce",
@@ -107,7 +110,12 @@ describe("cron delivery context", () => {
});
it("does not create delivery without a concrete target", () => {
expect(cronDeliveryFromContext({ channel: "matrix", to: " " })).toBeNull();
expect(
resolveCronCreationDelivery({
cfg,
currentDeliveryContext: { channel: "matrix", to: " " },
}),
).toBeNull();
expect(
resolveCronCreationDelivery({
cfg,
+1 -1
View File
@@ -8,7 +8,7 @@ import {
import type { CronDelivery, CronMessageChannel } from "./types.js";
/** Converts an active delivery context into cron announce delivery config. */
export function cronDeliveryFromContext(context?: DeliveryContext): CronDelivery | null {
function cronDeliveryFromContext(context?: DeliveryContext): CronDelivery | null {
const normalized = normalizeDeliveryContext(context);
if (!normalized?.to) {
return null;
+11 -17
View File
@@ -1,6 +1,7 @@
// Delivery preview tests cover dry-run delivery plan output for cron jobs.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeCronJob } from "./delivery.test-helpers.js";
import type { CronJob } from "./types.js";
const mocks = vi.hoisted(() => ({
resolveDeliveryTarget: vi.fn(),
@@ -10,7 +11,12 @@ vi.mock("./isolated-agent/delivery-target.js", () => ({
resolveDeliveryTarget: mocks.resolveDeliveryTarget,
}));
const { resolveCronDeliveryPreview } = await import("./delivery-preview.js");
const { resolveCronDeliveryPreviews } = await import("./delivery-preview.js");
async function previewForJob(job: CronJob) {
const previews = await resolveCronDeliveryPreviews({ cfg: {} as never, jobs: [job] });
return previews[job.id]!;
}
describe("resolveCronDeliveryPreview", () => {
beforeEach(() => {
@@ -31,10 +37,7 @@ describe("resolveCronDeliveryPreview", () => {
delivery: undefined,
});
const preview = await resolveCronDeliveryPreview({
cfg: {} as never,
job,
});
const preview = await previewForJob(job);
expect(mocks.resolveDeliveryTarget).toHaveBeenCalledWith(
{},
@@ -59,10 +62,7 @@ describe("resolveCronDeliveryPreview", () => {
sessionTarget: "isolated",
});
const preview = await resolveCronDeliveryPreview({
cfg: {} as never,
job,
});
const preview = await previewForJob(job);
expect(preview).toEqual({ label: "not requested", detail: "not requested" });
expect(mocks.resolveDeliveryTarget).not.toHaveBeenCalled();
@@ -81,10 +81,7 @@ describe("resolveCronDeliveryPreview", () => {
sessionTarget: "isolated",
});
const preview = await resolveCronDeliveryPreview({
cfg: {} as never,
job,
});
const preview = await previewForJob(job);
expect(mocks.resolveDeliveryTarget).toHaveBeenCalledWith(
{},
@@ -119,10 +116,7 @@ describe("resolveCronDeliveryPreview", () => {
sessionTarget: "isolated",
});
const preview = await resolveCronDeliveryPreview({
cfg: {} as never,
job,
});
const preview = await previewForJob(job);
expect(mocks.resolveDeliveryTarget).toHaveBeenCalledWith(
{},
+1 -1
View File
@@ -36,7 +36,7 @@ function formatDeliveryDetail(params: {
}
/** Builds the user-visible cron delivery preview for one job without sending anything. */
export async function resolveCronDeliveryPreview(params: {
async function resolveCronDeliveryPreview(params: {
cfg: OpenClawConfig;
defaultAgentId?: string;
job: CronJob;
-1
View File
@@ -1,3 +1,2 @@
// Public facade for isolated cron agent turn execution.
export { runCronIsolatedAgentTurn } from "./isolated-agent/run.js";
export type { RunCronAgentTurnResult } from "./isolated-agent/run.types.js";
+1 -107
View File
@@ -1,12 +1,6 @@
// Isolated agent helper tests cover low-level cron agent utilities.
import { describe, expect, it } from "vitest";
import {
isHeartbeatOnlyResponse,
pickDeliverablePayloads,
pickLastDeliverablePayload,
pickLastNonEmptyTextFromPayloads,
pickSummaryFromPayloads,
} from "./helpers.js";
import { isHeartbeatOnlyResponse, pickLastNonEmptyTextFromPayloads } from "./helpers.js";
type TextPayload = { text?: string | undefined; isError?: boolean | undefined };
@@ -16,36 +10,6 @@ const textPayloadPickerCases: Array<{
payloads: TextPayload[];
expected: string | undefined;
}> = [
{
name: "summary picks real text over error payload",
pick: pickSummaryFromPayloads,
payloads: [
{ text: "Here is your summary" },
{ text: "Tool error: rate limited", isError: true },
],
expected: "Here is your summary",
},
{
name: "summary falls back to error payload when no real text exists",
pick: pickSummaryFromPayloads,
payloads: [{ text: "Tool error: rate limited", isError: true }],
expected: "Tool error: rate limited",
},
{
name: "summary returns undefined for empty payloads",
pick: pickSummaryFromPayloads,
payloads: [],
expected: undefined,
},
{
name: "summary treats isError: undefined as non-error",
pick: pickSummaryFromPayloads,
payloads: [
{ text: "normal text", isError: undefined },
{ text: "error text", isError: true },
],
expected: "normal text",
},
{
name: "last non-empty text picks real text over error payload",
pick: pickLastNonEmptyTextFromPayloads,
@@ -81,76 +45,6 @@ describe("text payload pickers", () => {
});
});
describe("pickLastDeliverablePayload", () => {
it("picks real payload over error payload", () => {
const real = { text: "Delivered content" };
const error = { text: "Error warning", isError: true as const };
expect(pickLastDeliverablePayload([real, error])).toBe(real);
});
it("falls back to error payload when no real payload exists", () => {
const error = { text: "Error warning", isError: true as const };
expect(pickLastDeliverablePayload([error])).toBe(error);
});
it("returns undefined for empty payloads", () => {
expect(pickLastDeliverablePayload([])).toBeUndefined();
});
it("picks media payload over error text payload", () => {
const media = { mediaUrl: "https://example.com/img.png" };
const error = { text: "Error warning", isError: true as const };
expect(pickLastDeliverablePayload([media, error])).toBe(media);
});
it("picks presentation-only payload over error text payload", () => {
const presentation = {
presentation: {
blocks: [{ type: "buttons" as const, buttons: [{ label: "Ack", value: "ack" }] }],
},
};
const error = { text: "Error warning", isError: true as const };
expect(pickLastDeliverablePayload([presentation, error])).toBe(presentation);
});
it("treats isError: undefined as non-error", () => {
const normal = { text: "ok", isError: undefined };
const error = { text: "bad", isError: true as const };
expect(pickLastDeliverablePayload([normal, error])).toBe(normal);
});
});
describe("pickDeliverablePayloads", () => {
it("preserves all successful deliverable payloads", () => {
const payloads = [
{ text: "line 1" },
{ text: "temporary error", isError: true as const },
{ text: "line 2" },
];
expect(pickDeliverablePayloads(payloads)).toEqual([{ text: "line 1" }, { text: "line 2" }]);
});
it("preserves rich-only successful deliverable payloads", () => {
const presentation = {
presentation: {
blocks: [{ type: "buttons" as const, buttons: [{ label: "Ack", value: "ack" }] }],
},
};
expect(pickDeliverablePayloads([presentation])).toEqual([presentation]);
});
it("returns only the last error payload when all payloads are errors", () => {
const payloads = [
{ text: "first error", isError: true as const },
{ text: "last error", isError: true as const },
];
expect(pickDeliverablePayloads(payloads)).toEqual([{ text: "last error", isError: true }]);
});
});
describe("isHeartbeatOnlyResponse", () => {
const ACK_MAX = 300;
+3 -3
View File
@@ -92,7 +92,7 @@ export function pickSummaryFromOutput(text: string | undefined) {
}
/** Picks the last non-error payload text suitable for cron run summaries. */
export function pickSummaryFromPayloads(
function pickSummaryFromPayloads(
payloads: Array<{ text?: string | undefined; isError?: boolean }>,
) {
for (let i = payloads.length - 1; i >= 0; i--) {
@@ -162,7 +162,7 @@ function payloadHasStructuredDeliveryContent(payload: DeliveryPayload | null | u
}
/** Picks the last payload with deliverable outbound content, preferring non-error payloads. */
export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) {
function pickLastDeliverablePayload(payloads: DeliveryPayload[]) {
for (let i = payloads.length - 1; i >= 0; i--) {
if (payloads[i]?.isError) {
continue;
@@ -180,7 +180,7 @@ export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) {
}
/** Selects deliverable cron payloads while preserving multi-payload successful responses. */
export function pickDeliverablePayloads(payloads: DeliveryPayload[]): DeliveryPayload[] {
function pickDeliverablePayloads(payloads: DeliveryPayload[]): DeliveryPayload[] {
const successfulDeliverablePayloads = payloads.filter(
(payload) => payload != null && payload.isError !== true && isDeliverablePayload(payload),
);
+1 -1
View File
@@ -193,7 +193,7 @@ export type CronExecutionResult = {
};
/** Creates the model-fallback executor for one isolated cron prompt run. */
export function createCronPromptExecutor(params: {
function createCronPromptExecutor(params: {
cfg: OpenClawConfig;
cfgWithAgentDefaults: OpenClawConfig;
job: CronJob;
@@ -1,92 +0,0 @@
// Issue #91613 run-layer wiring: the keyless-inherited refusal now lives in resolveDeliveryTarget
// (returns ok:false), so resolveCronDeliveryContext simply passes it through — no flag the caller
// must remember to check. It also resolves the job's OWN session identity (sessionTarget takes
// precedence over sessionKey, like delivery preview) so a session-scoped cron is not misread as
// keyless. The resolver seam is mocked so this exercises the run-layer wiring in isolation.
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { CronJob } from "../types.js";
const resolveDeliveryTargetMock = vi.hoisted(() => vi.fn());
vi.mock("./run-delivery.runtime.js", () => ({
resolveDeliveryTarget: resolveDeliveryTargetMock,
}));
import { resolveCronDeliveryContext } from "./run.js";
// An isolated cron defaults to announce/"last" delivery, so it reaches the resolver.
const ISOLATED_JOB = {
sessionTarget: "isolated",
payload: { kind: "agentTurn" },
} as unknown as CronJob;
const CFG = {} as OpenClawConfig;
describe("resolveCronDeliveryContext — issue #91613 keyless-inherited wiring", () => {
it("passes through the resolver's ok:false refusal for a keyless-inherited target", async () => {
resolveDeliveryTargetMock.mockResolvedValueOnce({
ok: false,
channel: "alpha",
to: undefined,
mode: "implicit",
error: new Error(
"Refusing implicit isolated cron delivery: the target would be inherited from the shared " +
"agent-main session bucket's last recipient ... can deliver to the wrong room ...",
),
});
const result = await resolveCronDeliveryContext({
cfg: CFG,
job: ISOLATED_JOB,
agentId: "agent-x",
});
expect(result.resolvedDelivery.ok).toBe(false);
if (!result.resolvedDelivery.ok) {
expect(result.resolvedDelivery.error.message).toMatch(
/shared agent-main|inherited|wrong room/i,
);
}
});
it("delivers normally when the resolver resolves an explicit target", async () => {
resolveDeliveryTargetMock.mockResolvedValueOnce({
ok: true,
channel: "alpha",
to: "room:cron-own",
mode: "explicit",
});
const result = await resolveCronDeliveryContext({
cfg: CFG,
job: ISOLATED_JOB,
agentId: "agent-x",
});
expect(result.resolvedDelivery.ok).toBe(true);
if (result.resolvedDelivery.ok) {
expect(result.resolvedDelivery.to).toBe("room:cron-own");
}
});
it("resolves the job's sessionTarget into the resolver's sessionKey so a session-scoped cron is not misread as keyless", async () => {
resolveDeliveryTargetMock.mockResolvedValueOnce({
ok: true,
channel: "alpha",
to: "room:session-scoped",
mode: "implicit",
});
const sessionScopedJob = {
sessionTarget: "session:my-session",
payload: { kind: "agentTurn" },
} as unknown as CronJob;
await resolveCronDeliveryContext({ cfg: CFG, job: sessionScopedJob, agentId: "agent-x" });
expect(resolveDeliveryTargetMock).toHaveBeenCalledWith(
CFG,
"agent-x",
expect.objectContaining({ sessionKey: "my-session" }),
);
});
});
@@ -29,7 +29,7 @@ import {
} from "./run.test-harness.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
const { createCronPromptExecutor } = await import("./run-executor.js");
const { executeCronRun } = await import("./run-executor.js");
function makeMessageToolPolicyJob(
delivery: Record<string, unknown> = { mode: "none" },
@@ -336,53 +336,61 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
version: 1,
};
function createMessageToolExecutor(
overrides: Partial<Parameters<typeof createCronPromptExecutor>[0]>,
) {
const resolvedDelivery = overrides.resolvedDelivery ?? {};
return createCronPromptExecutor({
cfg: {},
cfgWithAgentDefaults: {},
job: makeMessageToolPolicyJob(),
agentId: "default",
agentDir: "/tmp/agent-dir",
agentSessionKey: "cron:message-tool-policy",
runSessionKey: "cron:message-tool-policy:run:test-session-id",
workspaceDir: "/tmp/workspace",
resolvedVerboseLevel: "off",
thinkLevel: undefined,
timeoutMs: 60_000,
suppressExecNotifyOnExit: true,
resolvedDeliveryOk: true,
messageToolPromptEnabled: true,
sourceDelivery: createSourceDeliveryPlan({
owner: "direct_fallback",
reason: "cron_announce",
target: {
channel: resolvedDelivery.channel ?? "messagechat",
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
threadId: resolvedDelivery.threadId,
},
messageToolEnabled: true,
messageToolForced: false,
requireExplicitMessageTarget: true,
requireExplicitMessageTargetEvidence: true,
directFallback: true,
}),
skillsSnapshot: emptySkillsSnapshot,
agentPayload: null,
useSubagentFallbacks: false,
liveSelection: {
provider: "openai",
model: "gpt-5.4",
},
cronSession: makeCronSession() as MutableCronSession,
abortReason: () => "aborted",
...overrides,
resolvedDelivery,
});
function createMessageToolExecutor(overrides: Record<string, unknown>) {
const resolvedDelivery = (overrides.resolvedDelivery ?? {}) as {
channel?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
return {
runPrompt: async (commandBody: string) =>
await executeCronRun({
cfg: {},
cfgWithAgentDefaults: {},
job: makeMessageToolPolicyJob(),
agentId: "default",
agentDir: "/tmp/agent-dir",
agentSessionKey: "cron:message-tool-policy",
runSessionKey: "cron:message-tool-policy:run:test-session-id",
workspaceDir: "/tmp/workspace",
agentVerboseDefault: undefined,
thinkLevel: undefined,
timeoutMs: 60_000,
suppressExecNotifyOnExit: true,
resolvedDeliveryOk: true,
messageToolPromptEnabled: true,
sourceDelivery: createSourceDeliveryPlan({
owner: "direct_fallback",
reason: "cron_announce",
target: {
channel: resolvedDelivery.channel ?? "messagechat",
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
threadId: resolvedDelivery.threadId,
},
messageToolEnabled: true,
messageToolForced: false,
requireExplicitMessageTarget: true,
requireExplicitMessageTargetEvidence: true,
directFallback: true,
}),
skillsSnapshot: emptySkillsSnapshot,
agentPayload: null,
useSubagentFallbacks: false,
liveSelection: {
provider: "openai",
model: "gpt-5.4",
},
cronSession: makeCronSession() as MutableCronSession,
commandBody,
persistSessionEntry: async () => undefined,
abortReason: () => "aborted",
isAborted: () => false,
...overrides,
resolvedDelivery,
} as never),
};
}
afterEach(() => {
@@ -15,7 +15,7 @@ import {
const actualDeliveryPlanModule =
await vi.importActual<typeof import("../delivery-plan.js")>("../delivery-plan.js");
const { createCronPromptExecutor, executeCronRun } = await import("./run-executor.js");
const { executeCronRun } = await import("./run-executor.js");
const { resolveCronSourceDeliveryPlan, resolveFallbackCronSourceDeliveryPlan } =
await import("./source-delivery-fallback.js");
@@ -44,36 +44,20 @@ function makeJob(
} as CronJob;
}
function makeExecutor(overrides: Partial<Parameters<typeof createCronPromptExecutor>[0]>) {
function makeExecutor(overrides: Record<string, unknown>) {
const resolvedDelivery = overrides.resolvedDelivery ?? {};
return createCronPromptExecutor({
cfg: {},
cfgWithAgentDefaults: {},
job: makeJob(),
agentId: "default",
agentDir: "/tmp/agent-dir",
agentSessionKey: "cron:source-delivery-guard",
runSessionKey: "cron:source-delivery-guard:run:test-session-id",
workspaceDir: "/tmp/workspace",
resolvedVerboseLevel: "off",
thinkLevel: undefined,
timeoutMs: 60_000,
suppressExecNotifyOnExit: true,
skillsSnapshot: emptySkillsSnapshot,
agentPayload: null,
useSubagentFallbacks: false,
liveSelection: {
provider: "openai",
model: "gpt-5.4",
},
cronSession: makeCronSession() as MutableCronSession,
abortReason: () => "aborted",
resolvedDeliveryOk: true,
messageToolPromptEnabled: true,
...overrides,
resolvedDelivery,
});
return {
runPrompt: async (commandBody: string) =>
await executeCronRun(
makeExecuteCronRunParams({
resolvedDeliveryOk: true,
messageToolPromptEnabled: true,
...overrides,
resolvedDelivery,
commandBody,
}),
),
};
}
function getEmbeddedRunArg(): Record<string, unknown> {
@@ -256,7 +240,7 @@ describe("resolveCronSourceDeliveryPlan", () => {
}
});
describe("createCronPromptExecutor sourceDelivery guard", () => {
describe("executeCronRun sourceDelivery mapping", () => {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
@@ -393,10 +393,8 @@ vi.mock("./model-preflight.runtime.js", () => ({
vi.mock("./helpers.js", () => ({
isHeartbeatOnlyResponse: isHeartbeatOnlyResponseMock,
pickLastDeliverablePayload: vi.fn().mockReturnValue(undefined),
pickLastNonEmptyTextFromPayloads: pickLastNonEmptyTextFromPayloadsMock,
pickSummaryFromOutput: vi.fn().mockReturnValue("summary"),
pickSummaryFromPayloads: vi.fn().mockReturnValue("summary"),
resolveCronPayloadOutcome: resolveCronPayloadOutcomeMock,
resolveHeartbeatAckMaxChars: resolveHeartbeatAckMaxCharsMock,
}));
@@ -1,6 +1,5 @@
// Tool allowlist tests cover tool availability for isolated cron runs.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MISSING_WEB_SEARCH_PROVIDER_DIAGNOSTIC_MESSAGE } from "../run-diagnostics.js";
import "../../agents/test-helpers/fast-coding-tools.js";
import {
listWebSearchProvidersMock,
@@ -14,6 +13,9 @@ import {
runWithModelFallbackMock,
} from "./run.test-harness.js";
const MISSING_WEB_SEARCH_PROVIDER_DIAGNOSTIC_MESSAGE =
"web_search tool requested in toolsAllow but no web search provider is selected. Configure one with: openclaw configure --section web, or set tools.web.search.provider.";
const RUN_TOOLS_ALLOW_TIMEOUT_MS = 300_000;
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
+2 -4
View File
@@ -235,8 +235,6 @@ async function retireRolledCronSessionMcpRuntime(params: {
});
}
export type { RunCronAgentTurnResult } from "./run.types.js";
type CronExecutionRuntime = typeof import("./run-executor.runtime.js");
type CronExecutionResult = Awaited<ReturnType<CronExecutionRuntime["executeCronRun"]>>;
type CronModelCatalogRuntime = typeof import("./run-model-catalog.runtime.js");
@@ -411,8 +409,8 @@ async function createCronToolsAllowPreflightDiagnostics(params: {
}
}
/** Exported for #91613 keyless-inherited delivery-context regression coverage. */
export async function resolveCronDeliveryContext(params: {
/** Resolves the delivery plan and concrete target for one isolated cron run. */
async function resolveCronDeliveryContext(params: {
cfg: OpenClawConfig;
job: CronJob;
agentId: string;
+4 -2
View File
@@ -3,13 +3,15 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
disableCronJobsBoundToSession,
resolveCronJobBoundSessionKeys,
type CronJobSessionBinding,
} from "./job-session-bindings.js";
import type { CronJob } from "./types.js";
const cfg = {} as OpenClawConfig;
function bindingKeys(job: CronJobSessionBinding, defaultAgentId?: string) {
function bindingKeys(
job: Parameters<typeof resolveCronJobBoundSessionKeys>[0],
defaultAgentId?: string,
) {
return resolveCronJobBoundSessionKeys(job, { cfg, defaultAgentId });
}
+1 -4
View File
@@ -6,10 +6,7 @@ import type { CronServiceContract } from "./service-contract.js";
import { resolveCronSessionTargetSessionKey } from "./session-target.js";
import type { CronJob } from "./types.js";
export type CronJobSessionBinding = Pick<
CronJob,
"id" | "agentId" | "sessionKey" | "sessionTarget"
>;
type CronJobSessionBinding = Pick<CronJob, "id" | "agentId" | "sessionKey" | "sessionTarget">;
/**
* Resolves every canonical session key a job is bound to: the session the run
+3 -1
View File
@@ -5,12 +5,14 @@ import {
createCronRunDiagnosticsFromMissingWebSearchProvider,
createCronRunDiagnosticsFromAgentResult,
createCronRunDiagnosticsFromError,
MISSING_WEB_SEARCH_PROVIDER_DIAGNOSTIC_MESSAGE,
mergeCronRunDiagnostics,
normalizeCronRunDiagnostics,
summarizeCronRunDiagnostics,
} from "./run-diagnostics.js";
const MISSING_WEB_SEARCH_PROVIDER_DIAGNOSTIC_MESSAGE =
"web_search tool requested in toolsAllow but no web search provider is selected. Configure one with: openclaw configure --section web, or set tools.web.search.provider.";
describe("cron run diagnostics", () => {
it("normalizes and bounds diagnostic entries", () => {
const diagnostics = normalizeCronRunDiagnostics({
+1 -1
View File
@@ -24,7 +24,7 @@ const MAX_SUMMARY_CHARS = 2_000;
const EXEC_DIAGNOSTIC_TAIL_CHARS = 2_000;
const WEB_SEARCH_TOOL_NAME = "web_search";
export const MISSING_WEB_SEARCH_PROVIDER_DIAGNOSTIC_MESSAGE =
const MISSING_WEB_SEARCH_PROVIDER_DIAGNOSTIC_MESSAGE =
"web_search tool requested in toolsAllow but no web search provider is selected. Configure one with: openclaw configure --section web, or set tools.web.search.provider.";
export function toolsAllowRequestsWebSearch(toolsAllow?: string[]): boolean {
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
CRON_TASK_RUN_SETTLEMENT_TRACKING_MAX_MS,
getSuspensionVisibleCronTaskRunCount,
resetActiveCronTaskRunsForTests,
retireActiveCronTaskRunTracking,
@@ -9,6 +8,8 @@ import {
waitForActiveCronTaskRuns,
} from "./active-run-cancellation.js";
const CRON_TASK_RUN_SETTLEMENT_TRACKING_MAX_MS = 60_000;
describe("cron task cancellation tracking", () => {
it("retires restart tracking while keeping an unsettled core suspension-visible", async () => {
resetActiveCronTaskRunsForTests();
+1 -1
View File
@@ -15,7 +15,7 @@ const settlingCronTaskRuns = new Map<Promise<unknown>, SettlingCronTaskRun>();
// host snapshot must keep refusing readiness until that core actually settles.
const suspensionVisibleCronTaskRuns = new Set<Promise<unknown>>();
const DEFAULT_CRON_TASK_RUN_DRAIN_POLL_MS = 25;
export const CRON_TASK_RUN_SETTLEMENT_TRACKING_MAX_MS = 60_000;
const CRON_TASK_RUN_SETTLEMENT_TRACKING_MAX_MS = 60_000;
export function startActiveCronTaskRunSettlementGrace(): void {
for (const [promise, entry] of settlingCronTaskRuns) {
+4 -5
View File
@@ -2,11 +2,10 @@
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it } from "vitest";
import type { CronJob } from "../types.js";
import {
AGENT_TURN_SAFETY_TIMEOUT_MS,
DEFAULT_JOB_TIMEOUT_MS,
resolveCronJobTimeoutMs,
} from "./timeout-policy.js";
import { resolveCronJobTimeoutMs } from "./timeout-policy.js";
const DEFAULT_JOB_TIMEOUT_MS = 10 * 60_000;
const AGENT_TURN_SAFETY_TIMEOUT_MS = 60 * 60_000;
function makeJob(payload: CronJob["payload"]): CronJob {
const sessionTarget = payload.kind === "agentTurn" ? "isolated" : "main";
+2 -2
View File
@@ -7,13 +7,13 @@ import type { CronJob } from "../types.js";
* on top of per-provider/per-agent timeouts to prevent one stuck job from
* wedging the entire cron lane.
*/
export const DEFAULT_JOB_TIMEOUT_MS = 10 * 60_000; // 10 minutes
const DEFAULT_JOB_TIMEOUT_MS = 10 * 60_000; // 10 minutes
/**
* Agent turns can legitimately run much longer than generic cron jobs.
* Use a larger safety ceiling when no explicit timeout is set.
*/
export const AGENT_TURN_SAFETY_TIMEOUT_MS = 60 * 60_000; // 60 minutes
const AGENT_TURN_SAFETY_TIMEOUT_MS = 60 * 60_000; // 60 minutes
/** Resolves the wall-clock timeout for a cron job, including explicit detached-run overrides. */
export function resolveCronJobTimeoutMs(job: CronJob): number | undefined {
+1 -2
View File
@@ -41,7 +41,6 @@ import {
import { computeJobNextRunAtMs } from "./jobs.js";
import { run as runManualCronJob } from "./ops.js";
import { createCronServiceState, type CronEvent } from "./state.js";
import { DEFAULT_JOB_TIMEOUT_MS } from "./timeout-policy.js";
import {
applyJobResult,
executeJobCore,
@@ -958,7 +957,7 @@ describe("cron service timer regressions", () => {
settled = true;
});
await vi.advanceTimersByTimeAsync(DEFAULT_JOB_TIMEOUT_MS + 1_000);
await vi.advanceTimersByTimeAsync(10 * 60_000 + 1_000);
await Promise.resolve();
expect(settled).toBe(false);
+21 -25
View File
@@ -9,7 +9,7 @@ import { isCronRunSessionKey } from "../sessions/session-key-utils.js";
import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js";
import { createDeferred } from "../test-utils/deferred.js";
import type { Logger } from "./service/state.js";
import { sweepCronRunSessions, resolveRetentionMs, resetReaperThrottle } from "./session-reaper.js";
import { resetReaperThrottle, sweepCronRunSessions } from "./session-reaper.js";
const { listSessionEntries, patchSessionEntry, replaceSessionEntry } = sessionAccessor;
@@ -43,30 +43,6 @@ function readSessionEntries(storePath: string): Record<string, SessionEntry> {
);
}
describe("resolveRetentionMs", () => {
it("returns 24h default when no config", () => {
expect(resolveRetentionMs()).toBe(24 * 3_600_000);
});
it("returns 24h default when config is empty", () => {
expect(resolveRetentionMs({})).toBe(24 * 3_600_000);
});
it("parses duration string", () => {
expect(resolveRetentionMs({ sessionRetention: "1h" })).toBe(3_600_000);
expect(resolveRetentionMs({ sessionRetention: "7d" })).toBe(7 * 86_400_000);
expect(resolveRetentionMs({ sessionRetention: "30m" })).toBe(30 * 60_000);
});
it("returns null when disabled", () => {
expect(resolveRetentionMs({ sessionRetention: false })).toBeNull();
});
it("falls back to default on invalid string", () => {
expect(resolveRetentionMs({ sessionRetention: "abc" })).toBe(24 * 3_600_000);
});
});
describe("isCronRunSessionKey", () => {
it("matches cron run session keys", () => {
expect(isCronRunSessionKey("agent:main:cron:abc-123:run:def-456")).toBe(true);
@@ -169,6 +145,26 @@ describe("sweepCronRunSessions", () => {
});
});
it("falls back to the default retention when the configured duration is invalid", async () => {
const now = Date.now();
await seedSessionEntries(storePath, {
"agent:main:cron:job1:run:old-run": {
sessionId: "old-run",
updatedAt: now - 25 * 3_600_000,
},
});
const result = await sweepCronRunSessions({
cronConfig: { sessionRetention: "not-a-duration" },
sessionStorePath: storePath,
nowMs: now,
log,
force: true,
});
expect(result).toEqual({ swept: true, pruned: 1 });
});
it("preserves expired continuation rows while generated media is pending", async () => {
const now = Date.now();
const sessionKey = "agent:main:cron:job1:run:pending-run";
+1 -1
View File
@@ -20,7 +20,7 @@ const MIN_SWEEP_INTERVAL_MS = 5 * 60_000; // 5 minutes
const lastSweepAtMsByStore = new Map<string, number>();
/** Resolves cron run-session retention; `false` disables pruning, bad strings fall back safely. */
export function resolveRetentionMs(cronConfig?: CronConfig): number | null {
function resolveRetentionMs(cronConfig?: CronConfig): number | null {
if (cronConfig?.sessionRetention === false) {
return null; // pruning disabled
}
+22 -16
View File
@@ -1,29 +1,35 @@
// Cron stagger tests cover deterministic schedule spreading across jobs.
import { describe, expect, it } from "vitest";
import {
isRecurringTopOfHourCronExpr,
normalizeCronStaggerMs,
resolveCronStaggerMs,
} from "./stagger.js";
import { normalizeCronStaggerMs, resolveCronStaggerMs } from "./stagger.js";
const DEFAULT_TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1000;
describe("cron stagger helpers", () => {
it("detects recurring top-of-hour cron expressions for 5-field and 6-field cron", () => {
expect(isRecurringTopOfHourCronExpr("0 * * * *")).toBe(true);
expect(isRecurringTopOfHourCronExpr("0 */2 * * *")).toBe(true);
expect(isRecurringTopOfHourCronExpr("0 0 */3 * * *")).toBe(true);
expect(isRecurringTopOfHourCronExpr("0 */2,3 * * *")).toBe(true);
expect(isRecurringTopOfHourCronExpr("0 */2,? * * *")).toBe(true);
expect(isRecurringTopOfHourCronExpr("0 7 * * *")).toBe(false);
expect(isRecurringTopOfHourCronExpr("15 * * * *")).toBe(false);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 * * * *" })).toBe(
DEFAULT_TOP_OF_HOUR_STAGGER_MS,
);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 */2 * * *" })).toBe(
DEFAULT_TOP_OF_HOUR_STAGGER_MS,
);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 0 */3 * * *" })).toBe(
DEFAULT_TOP_OF_HOUR_STAGGER_MS,
);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 */2,3 * * *" })).toBe(
DEFAULT_TOP_OF_HOUR_STAGGER_MS,
);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 */2,? * * *" })).toBe(
DEFAULT_TOP_OF_HOUR_STAGGER_MS,
);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 7 * * *" })).toBe(0);
expect(resolveCronStaggerMs({ kind: "cron", expr: "15 * * * *" })).toBe(0);
});
it("rejects malformed hour fields that merely contain a wildcard", () => {
expect(isRecurringTopOfHourCronExpr("0 5* * * *")).toBe(false);
expect(isRecurringTopOfHourCronExpr("0 *5 * * *")).toBe(false);
expect(isRecurringTopOfHourCronExpr("0 1-*/2 * * *")).toBe(false);
expect(isRecurringTopOfHourCronExpr("0 0 5* * * *")).toBe(false);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 5* * * *" })).toBe(0);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 *5 * * *" })).toBe(0);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 1-*/2 * * *" })).toBe(0);
expect(resolveCronStaggerMs({ kind: "cron", expr: "0 0 5* * * *" })).toBe(0);
});
it("normalizes explicit stagger values", () => {
+1 -1
View File
@@ -21,7 +21,7 @@ function hasRecurringWildcardHour(field: string): boolean {
}
/** Returns whether a cron expression fires recurring jobs exactly at the top of an hour. */
export function isRecurringTopOfHourCronExpr(expr: string) {
function isRecurringTopOfHourCronExpr(expr: string) {
const fields = parseCronFields(expr);
if (fields.length === 5) {
const [minuteField, hourField] = fields;
+4 -9
View File
@@ -2,15 +2,12 @@
// on-exit command/cwd persistence (v1 reuses schedule_expr/schedule_tz) is
// covered alongside the existing kinds.
import { describe, expect, it } from "vitest";
import { makeCronJob } from "../delivery.test-helpers.js";
import type { CronSchedule } from "../types.js";
import { bindScheduleColumns, scheduleFromRow } from "./row-codec.js";
import type { CronJobRow } from "./schema.js";
import { projectCronJobThroughStorageCodec } from "./row-codec.js";
function roundTrip(schedule: CronSchedule): CronSchedule | null {
const cols = bindScheduleColumns(schedule);
// scheduleFromRow only reads the schedule_* / at / every_ms / anchor_ms /
// stagger_ms columns; the rest of the row is irrelevant here.
return scheduleFromRow(cols as unknown as CronJobRow);
return projectCronJobThroughStorageCodec(makeCronJob({ schedule })).schedule;
}
describe("schedule column codec round-trip", () => {
@@ -46,9 +43,7 @@ describe("schedule column codec round-trip", () => {
});
it("an on-exit row is decoded as on-exit, not cron (schedule_kind disambiguates)", () => {
const cols = bindScheduleColumns({ kind: "on-exit", command: "sleep 5" });
expect(cols.schedule_kind).toBe("on-exit");
const decoded = scheduleFromRow(cols as unknown as CronJobRow);
const decoded = roundTrip({ kind: "on-exit", command: "sleep 5" });
expect(decoded?.kind).toBe("on-exit");
});
});
+2 -2
View File
@@ -22,7 +22,7 @@ import { bindStateColumns, stateFromRow } from "./state-codec.js";
import { bindTriggerColumns, triggerFromRow } from "./trigger-codec.js";
import type { LoadedCronStore } from "./types.js";
export function bindScheduleColumns(
function bindScheduleColumns(
schedule: CronSchedule,
): Pick<
CronJobInsert,
@@ -210,7 +210,7 @@ export function assertCronStoreCanPersist(store: CronStoreFile): void {
}
}
export function scheduleFromRow(row: CronJobRow): CronSchedule | null {
function scheduleFromRow(row: CronJobRow): CronSchedule | null {
if (row.schedule_kind === "at" && row.at) {
return { kind: "at", at: row.at };
}
+1 -1
View File
@@ -13,7 +13,7 @@ import type { CronDeliveryStatus, CronRunStatus } from "./types.js";
type CronRunHistorySortDir = "asc" | "desc";
type CronRunHistoryStatusFilter = "all" | CronRunStatus;
export type ReadCronTaskRunHistoryPageOptions = {
type ReadCronTaskRunHistoryPageOptions = {
storeKey: string;
limit?: number;
offset?: number;