Compare commits

...

4 Commits

Author SHA1 Message Date
Mariano Belinky
feebf7a1e6 docs: clarify command cron admin boundary 2026-06-03 20:34:30 +02:00
Mariano Belinky
732ceadfb7 fix(doctor): flag cron agent shell prompts 2026-06-03 19:41:10 +02:00
Mariano Belinky
41339c6370 test cron command edge cases 2026-06-03 19:33:02 +02:00
Mariano Belinky
25f3c2a22b feat: support command cron jobs 2026-06-03 19:33:02 +02:00
44 changed files with 1999 additions and 103 deletions

View File

@@ -122,6 +122,33 @@ This fires ~56 times per month instead of 01 times per month. OpenClaw use
</Accordion>
</AccordionGroup>
### Command payloads
Use command payloads for deterministic scripts that should run inside the Gateway scheduler without starting a model-backed isolated agent turn. Command jobs execute on the Gateway host, capture stdout/stderr, record the run in cron history, and reuse the same `announce`, `webhook`, and `none` delivery modes as isolated jobs.
<Note>
Command cron is an operator-admin Gateway automation surface, not an agent
`tools.exec` call. Creating, updating, removing, or manually running cron jobs
requires `operator.admin`; scheduled command runs later execute inside the
Gateway process as that admin-authored automation. Agent exec policy such as
`tools.exec.mode`, approval prompts, and per-agent tool allowlists governs
model-visible exec tools, not command cron payloads.
</Note>
```bash
openclaw cron create "*/15 * * * *" \
--name "Queue depth probe" \
--command "scripts/check-queue.sh" \
--command-cwd "/srv/app" \
--announce \
--channel telegram \
--to "-1001234567890"
```
`--command <shell>` stores `argv: ["sh", "-lc", <shell>]`. Use `--command-argv '["node","scripts/report.mjs"]'` when you want exact argv execution without shell parsing. Optional `--command-env KEY=VALUE`, `--command-input`, `--timeout-seconds`, `--no-output-timeout-seconds`, and `--output-max-bytes` fields control the process environment, stdin, and output bounds.
If stdout is non-empty, that text is the delivered result. If stdout is empty and stderr is non-empty, stderr is delivered. If both streams are present, cron delivers a small `stdout:` / `stderr:` block. A zero exit code records the run as `ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and can trigger failure alerts. A command that prints only `NO_REPLY` uses the normal cron silent-token suppression and posts nothing back to chat.
### Payload options for isolated jobs
<ParamField path="--message" type="string" required>
@@ -246,6 +273,17 @@ Failure notifications follow a separate destination path:
--webhook "https://example.invalid/openclaw/cron"
```
</Tab>
<Tab title="Command output">
```bash
openclaw cron create "*/15 * * * *" \
--name "Queue depth probe" \
--command "scripts/check-queue.sh" \
--command-cwd "/srv/app" \
--announce \
--channel telegram \
--to "-1001234567890"
```
</Tab>
</Tabs>
## Webhooks

View File

@@ -34,6 +34,27 @@ openclaw cron create "0 18 * * 1-5" \
--webhook "https://example.invalid/openclaw/cron"
```
Use `--command` for deterministic shell-style jobs that should run inside OpenClaw cron without starting an isolated agent/model run:
<Note>
Command cron jobs are admin-authored Gateway automation. Creating, editing,
removing, or manually running them requires `operator.admin`; the scheduled run
later executes in the Gateway process, not as an agent `tools.exec` tool call.
`tools.exec.*` and exec approvals still govern model-visible exec tools.
</Note>
```bash
openclaw cron create "*/15 * * * *" \
--name "Queue depth probe" \
--command "scripts/check-queue.sh" \
--command-cwd "/srv/app" \
--announce \
--channel telegram \
--to "-1001234567890"
```
`--command <shell>` stores `argv: ["sh", "-lc", <shell>]`. Use `--command-argv '["node","scripts/report.mjs"]'` for exact argv execution. Command jobs capture stdout/stderr, record normal cron history, and route output through the same `announce`, `webhook`, or `none` delivery modes as isolated jobs. A command that prints only `NO_REPLY` is suppressed.
## Sessions
`--session` accepts `main`, `isolated`, `current`, or `session:<id>`.
@@ -92,6 +113,10 @@ Note: isolated cron runs treat run-level agent failures as job errors even when
no reply payload is produced, so model/provider failures still increment error
counters and trigger failure notifications.
Command cron jobs do not start an isolated agent turn. A zero exit code records
`ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and
can trigger the same failure notification path.
If an isolated run times out before the first model request, `openclaw cron show`
and `openclaw cron runs` include a phase-specific error such as
`setup timed out before runner start` or
@@ -252,6 +277,21 @@ openclaw cron create "0 7 * * *" \
`--light-context` applies to isolated agent-turn jobs only. For cron runs, lightweight mode keeps bootstrap context empty instead of injecting the full workspace bootstrap set.
Create a command job with exact argv, cwd, env, stdin, and output limits:
```bash
openclaw cron create "*/30 * * * *" \
--name "Position export" \
--command-argv '["node","scripts/export-position.mjs"]' \
--command-cwd "/srv/app" \
--command-env "NODE_ENV=production" \
--command-input '{"mode":"summary"}' \
--timeout-seconds 120 \
--no-output-timeout-seconds 30 \
--output-max-bytes 65536 \
--webhook "https://example.invalid/openclaw/cron"
```
## Common admin commands
Manual run and inspection:

View File

@@ -45,6 +45,36 @@ describe("cron protocol validators", () => {
).toBe(true);
});
it("accepts command cron payloads", () => {
expect(
validateCronAddParams({
...minimalAddParams,
sessionTarget: "isolated",
payload: {
kind: "command",
argv: ["sh", "-lc", "echo ok"],
cwd: "/srv/example",
env: { FOO: "bar" },
input: "stdin",
timeoutSeconds: 30,
noOutputTimeoutSeconds: 5,
outputMaxBytes: 4096,
},
}),
).toBe(true);
expect(
validateCronUpdateParams({
id: "job-1",
patch: {
payload: {
kind: "command",
argv: ["sh", "-lc", "echo updated"],
},
},
}),
).toBe(true);
});
it("rejects add params when required scheduling fields are missing", () => {
const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams;
expect(validateCronAddParams(withoutWakeMode)).toBe(false);

View File

@@ -18,6 +18,22 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema; toolsAllow: TSch
);
}
function cronCommandPayloadSchema(params: { argv: TSchema }) {
return Type.Object(
{
kind: Type.Literal("command"),
argv: params.argv,
cwd: Type.Optional(Type.String({ minLength: 1 })),
env: Type.Optional(Type.Record(Type.String({ minLength: 1 }), Type.String())),
input: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
noOutputTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
outputMaxBytes: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
);
}
const CronSessionTargetSchema = Type.Union([
Type.Literal("main"),
Type.Literal("isolated"),
@@ -198,6 +214,9 @@ export const CronPayloadSchema = Type.Union([
message: NonEmptyString,
toolsAllow: Type.Array(Type.String()),
}),
cronCommandPayloadSchema({
argv: Type.Array(NonEmptyString, { minItems: 1 }),
}),
]);
export const CronPayloadPatchSchema = Type.Union([
@@ -212,6 +231,9 @@ export const CronPayloadPatchSchema = Type.Union([
message: Type.Optional(NonEmptyString),
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
}),
cronCommandPayloadSchema({
argv: Type.Optional(Type.Array(NonEmptyString, { minItems: 1 })),
}),
]);
export const CronFailureAlertSchema = Type.Object(

View File

@@ -633,6 +633,23 @@ describe("cron tool", () => {
expect(params?.failureAlert).toBe(false);
});
it("rejects command payloads from the agent cron tool on add", async () => {
const tool = createTestCronTool();
await expect(
tool.execute("call-command-add", {
action: "add",
job: {
name: "command",
schedule: { at: new Date(123).toISOString() },
sessionTarget: "isolated",
payload: { kind: "command", argv: ["sh", "-lc", "echo ok"] },
},
}),
).rejects.toThrow("cron command payloads cannot be created or edited");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it.each([
["delivery.channel", { channel: " ", to: "chat-1" }],
["delivery.to", { mode: "announce", channel: "telegram", to: " \t" }],
@@ -1454,6 +1471,21 @@ describe("cron tool", () => {
expect(params?.patch?.failureAlert).toBe(false);
});
it("rejects command payloads from the agent cron tool on update", async () => {
const tool = createTestCronTool();
await expect(
tool.execute("call-command-update", {
action: "update",
id: "job-4",
patch: {
payload: { kind: "command", argv: ["sh", "-lc", "echo ok"] },
},
}),
).rejects.toThrow("cron command payloads cannot be created or edited");
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("recovers flattened payload patch params for update action", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });

View File

@@ -334,6 +334,18 @@ function stripExistingContext(text: string) {
return text.slice(0, index).trim();
}
function assertNoCronCommandPayload(value: unknown): void {
if (!isRecord(value)) {
return;
}
const payload = isRecord(value.payload) ? value.payload : undefined;
if (payload?.kind === "command") {
throw new Error(
"cron command payloads cannot be created or edited through the agent cron tool; use the CLI or Gateway API.",
);
}
}
function truncateText(input: string, maxLen: number) {
if (input.length <= maxLen) {
return input;
@@ -652,6 +664,7 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
throw new Error("job required");
}
const canonicalJob = canonicalizeCronToolObject(params.job as Record<string, unknown>);
assertNoCronCommandPayload(canonicalJob);
assertCronDeliveryInputNonBlankFields(canonicalJob.delivery);
const job =
normalizeCronJobCreate(canonicalJob, {
@@ -769,6 +782,7 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
const canonicalPatch = canonicalizeCronToolObject(
params.patch as Record<string, unknown>,
);
assertNoCronCommandPayload(canonicalPatch);
assertCronDeliveryInputNonBlankFields(canonicalPatch.delivery);
const patch = normalizeCronJobPatch(canonicalPatch) ?? canonicalPatch;
if (recoveredFlatPatch && isEmptyRecoveredCronPatch(patch)) {

View File

@@ -61,10 +61,17 @@ type CronUpdatePatch = {
schedule?: { kind?: string; expr?: string; tz?: string; staggerMs?: number };
payload?: {
kind?: string;
argv?: string[];
cwd?: string;
env?: Record<string, string>;
input?: string;
message?: string;
model?: string;
thinking?: string;
lightContext?: boolean;
timeoutSeconds?: number;
noOutputTimeoutSeconds?: number;
outputMaxBytes?: number;
toolsAllow?: string[];
};
delivery?: {
@@ -83,10 +90,17 @@ type CronAddParams = {
schedule?: { kind?: string; at?: string; expr?: string; everyMs?: number; staggerMs?: number };
payload?: {
kind?: string;
argv?: string[];
cwd?: string;
env?: Record<string, string>;
input?: string;
message?: string;
model?: string;
thinking?: string;
lightContext?: boolean;
timeoutSeconds?: number;
noOutputTimeoutSeconds?: number;
outputMaxBytes?: number;
toolsAllow?: string[];
};
delivery?: {
@@ -582,6 +596,57 @@ describe("cron cli", () => {
expect(params?.payload?.kind).toBe("agentTurn");
});
it("creates command cron payloads without an agent-turn message", async () => {
const params = await runCronAddAndGetParams([
"--name",
"Shell probe",
"--every",
"10m",
"--command",
"echo ok",
"--command-cwd",
"/srv/app",
"--command-env",
"FOO=bar",
"--timeout-seconds",
"30",
"--no-output-timeout-seconds",
"5",
"--output-max-bytes",
"4096",
"--no-deliver",
]);
expect(params?.sessionTarget).toBe("isolated");
expect(params?.payload).toMatchObject({
kind: "command",
argv: ["sh", "-lc", "echo ok"],
cwd: "/srv/app",
env: { FOO: "bar" },
timeoutSeconds: 30,
noOutputTimeoutSeconds: 5,
outputMaxBytes: 4096,
});
expect(params?.delivery?.mode).toBe("none");
});
it("rejects cron add with both message and command payloads", async () => {
await expectCronCommandExit([
"cron",
"add",
"--name",
"Ambiguous",
"--cron",
"* * * * *",
"--message",
"hello",
"--command",
"echo ok",
]);
expectRuntimeErrorContaining("Choose exactly one payload");
});
it("supports --keep-after-run on cron add", async () => {
await runCronCommand([
"cron",
@@ -1006,6 +1071,56 @@ describe("cron cli", () => {
expect(patch?.patch?.payload?.thinking).toBe("low");
});
it("converts cron edit payloads to command argv", async () => {
const patch = await runCronEditAndGetPatch([
"--command-argv",
'["node","scripts/report.mjs"," "]',
"--command-cwd",
"/srv/app",
]);
expect(patch?.patch?.payload).toEqual({
kind: "command",
argv: ["node", "scripts/report.mjs", " "],
cwd: "/srv/app",
});
});
it("updates command cron timeout without requiring argv to be repeated", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "cron.status") {
return { enabled: true };
}
if (method === "cron.list") {
return {
jobs: [
{
...createCronJob("job-1", "Command"),
payload: { kind: "command", argv: ["sh", "-lc", "echo ok"] },
},
],
};
}
return { ok: true, params };
},
);
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", "--timeout-seconds", "120"], {
from: "user",
});
const patch = getGatewayCallParams<{
patch?: { payload?: { kind?: string; timeoutSeconds?: number } };
}>("cron.update");
expect(patch?.patch?.payload).toEqual({
kind: "command",
timeoutSeconds: 120,
});
});
it("sets and clears lightContext on cron edit", async () => {
const setPatch = await runCronEditAndGetPatch(["--light-context", "--message", "hello"]);
expect(setPatch?.patch?.payload?.lightContext).toBe(true);
@@ -1033,7 +1148,7 @@ describe("cron cli", () => {
};
}>("cron.update");
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload).toBeUndefined();
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.channel).toBe("telegram");
expect(patch?.patch?.delivery?.to).toBe("19098680");
@@ -1051,7 +1166,7 @@ describe("cron cli", () => {
"42",
]);
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload).toBeUndefined();
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.channel).toBe("telegram");
expect(patch?.patch?.delivery?.to).toBe("-100123");
@@ -1061,7 +1176,7 @@ describe("cron cli", () => {
it("preserves existing delivery mode on thread-only cron edit patches", async () => {
const patch = await runCronEditAndGetPatch(["--thread-id", "42"]);
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload).toBeUndefined();
expect(patch?.patch?.delivery?.mode).toBeUndefined();
expect(patch?.patch?.delivery?.threadId).toBe(42);
});
@@ -1132,7 +1247,7 @@ describe("cron cli", () => {
it("updates delivery account without requiring --message on cron edit", async () => {
const patch = await runCronEditAndGetPatch(["--account", " coordinator "]);
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload).toBeUndefined();
expect(patch?.patch?.delivery?.accountId).toBe("coordinator");
expect(patch?.patch?.delivery?.mode).toBeUndefined();
});

View File

@@ -16,6 +16,8 @@ import {
coerceCronDeliveryPreviews,
enrichCronJsonWithStatus,
handleCronCliError,
parseCronCommandArgv,
parseCronCommandEnv,
parseCronToolsAllow,
printCronJson,
printCronList,
@@ -104,12 +106,23 @@ export function registerCronAddCommand(cron: Command) {
.option("--exact", "Disable cron staggering (set stagger to 0)", false)
.option("--system-event <text>", "System event payload (main session)")
.option("--message <text>", "Agent message payload")
.option("--command <shell>", "Command payload run as sh -lc <shell> on the Gateway")
.option("--command-argv <json>", "Command payload argv as JSON array of strings")
.option("--command-cwd <path>", "Working directory for command payloads")
.option(
"--command-env <KEY=VALUE>",
"Environment override for command payloads (repeatable)",
(value: string, previous: string[] = []) => [...previous, value],
)
.option("--command-input <text>", "stdin for command payloads")
.option(
"--thinking <level>",
"Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
)
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent or command jobs")
.option("--no-output-timeout-seconds <n>", "No-output timeout seconds for command jobs")
.option("--output-max-bytes <n>", "Maximum captured stdout/stderr bytes for command jobs")
.option("--light-context", "Use lightweight bootstrap context for agent jobs", false)
.option("--tools <list>", "Tool allow-list (e.g. exec,read,write or exec read write)")
.option("--announce", "Fallback-deliver final text to a chat", false)
@@ -176,20 +189,59 @@ export function registerCronAddCommand(cron: Command) {
const systemEvent = normalizeOptionalString(opts.systemEvent) ?? "";
const optionMessage = normalizeOptionalString(opts.message);
const positionalMessage = normalizeOptionalString(messageArg);
const commandShell = normalizeOptionalString(opts.command);
const commandArgv = parseCronCommandArgv(opts.commandArgv);
if (optionMessage && positionalMessage && optionMessage !== positionalMessage) {
throw new Error(
"Pass the cron job message either positionally or with --message, not both.",
);
}
const message = optionMessage ?? positionalMessage ?? "";
const chosen = [Boolean(systemEvent), Boolean(message)].filter(Boolean).length;
if (commandShell && commandArgv) {
throw new Error(
"Pass command payload either with --command or --command-argv, not both.",
);
}
const chosen = [
Boolean(systemEvent),
Boolean(message),
Boolean(commandShell) || Boolean(commandArgv),
].filter(Boolean).length;
if (chosen !== 1) {
throw new Error("Choose exactly one payload: --system-event or --message");
throw new Error(
"Choose exactly one payload: --system-event, --message, or --command",
);
}
if (systemEvent) {
return { kind: "systemEvent" as const, text: systemEvent };
}
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
if (commandShell || commandArgv) {
const rawNoOutputTimeoutSeconds =
opts.noOutputTimeoutSeconds ??
(typeof opts.outputTimeoutSeconds === "string" ||
typeof opts.outputTimeoutSeconds === "number"
? opts.outputTimeoutSeconds
: undefined);
const noOutputTimeoutSeconds =
parsePositiveIntOrUndefined(rawNoOutputTimeoutSeconds);
const outputMaxBytes = parsePositiveIntOrUndefined(opts.outputMaxBytes);
return {
kind: "command" as const,
argv: commandArgv ?? ["sh", "-lc", commandShell ?? ""],
cwd: normalizeOptionalString(opts.commandCwd),
env: parseCronCommandEnv(opts.commandEnv),
input: typeof opts.commandInput === "string" ? opts.commandInput : undefined,
timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
noOutputTimeoutSeconds:
noOutputTimeoutSeconds && Number.isFinite(noOutputTimeoutSeconds)
? noOutputTimeoutSeconds
: undefined,
outputMaxBytes:
outputMaxBytes && Number.isFinite(outputMaxBytes) ? outputMaxBytes : undefined,
};
}
return {
kind: "agentTurn" as const,
message,
@@ -204,7 +256,8 @@ export function registerCronAddCommand(cron: Command) {
const sessionSource = optionSource("session");
const sessionTargetRaw = normalizeOptionalString(opts.session) ?? "";
const inferredSessionTarget = payload.kind === "agentTurn" ? "isolated" : "main";
const inferredSessionTarget =
payload.kind === "agentTurn" || payload.kind === "command" ? "isolated" : "main";
const sessionTarget =
sessionSource === "cli"
? normalizeCronSessionTargetOption(sessionTargetRaw) || ""
@@ -225,17 +278,22 @@ export function registerCronAddCommand(cron: Command) {
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
throw new Error("Main jobs require --system-event (systemEvent).");
}
if (isIsolatedLikeSessionTarget && payload.kind !== "agentTurn") {
if (
isIsolatedLikeSessionTarget &&
payload.kind !== "agentTurn" &&
payload.kind !== "command"
) {
throw new Error(
"Isolated/current/custom-session jobs require --message (agentTurn).",
"Isolated/current/custom-session jobs require --message (agentTurn) or --command.",
);
}
if (
(opts.announce || typeof opts.deliver === "boolean") &&
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
(!isIsolatedLikeSessionTarget ||
(payload.kind !== "agentTurn" && payload.kind !== "command"))
) {
throw new Error(
"--announce/--no-deliver require a non-main agentTurn session target.",
"--announce/--no-deliver require a non-main agentTurn or command session target.",
);
}
@@ -250,10 +308,11 @@ export function registerCronAddCommand(cron: Command) {
if (
(accountId || hasThreadId) &&
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
(!isIsolatedLikeSessionTarget ||
(payload.kind !== "agentTurn" && payload.kind !== "command"))
) {
throw new Error(
"--account and --thread-id require a non-main agentTurn job with delivery.",
"--account and --thread-id require a non-main agentTurn or command job with delivery.",
);
}
if (hasWebhook && hasChatDeliveryTarget) {
@@ -262,7 +321,8 @@ export function registerCronAddCommand(cron: Command) {
const deliveryMode = hasWebhook
? "webhook"
: isIsolatedLikeSessionTarget && payload.kind === "agentTurn"
: isIsolatedLikeSessionTarget &&
(payload.kind === "agentTurn" || payload.kind === "command")
? hasAnnounce
? "announce"
: hasNoDeliver
@@ -286,7 +346,7 @@ export function registerCronAddCommand(cron: Command) {
const sessionKey = normalizeOptionalString(opts.sessionKey);
if (payload.kind === "agentTurn" && !agentId) {
if ((payload.kind === "agentTurn" || payload.kind === "command") && !agentId) {
defaultRuntime.error(
theme.warn(
"No --agent specified; the job will run with the configured default agent. " +

View File

@@ -35,7 +35,7 @@ describe("cron edit command", () => {
expect(help).toMatch(/also\s+implies --announce when used alone/);
});
it("keeps the documented --best-effort-deliver-only patch behavior (#83908)", async () => {
it("keeps --best-effort-deliver-only edits delivery-only (#83908)", async () => {
const program = createCronProgram();
await program.parseAsync(["edit", "job-1", "--best-effort-deliver"], { from: "user" });
@@ -46,7 +46,6 @@ describe("cron edit command", () => {
{
id: "job-1",
patch: {
payload: { kind: "agentTurn" },
delivery: {
mode: "announce",
bestEffort: true,
@@ -56,7 +55,7 @@ describe("cron edit command", () => {
);
});
it("does not imply announce mode for --no-best-effort-deliver alone", async () => {
it("keeps --no-best-effort-deliver-only edits delivery-only", async () => {
const program = createCronProgram();
await program.parseAsync(["edit", "job-1", "--no-best-effort-deliver"], { from: "user" });
@@ -67,7 +66,6 @@ describe("cron edit command", () => {
{
id: "job-1",
patch: {
payload: { kind: "agentTurn" },
delivery: {
bestEffort: false,
},
@@ -75,4 +73,37 @@ describe("cron edit command", () => {
},
);
});
it("preserves command payload kind for timeout-only edits", async () => {
callGatewayFromCli.mockImplementation(async (method: string) => {
if (method === "cron.list") {
return {
jobs: [
{
id: "job-1",
payload: { kind: "command", argv: ["sh", "-lc", "echo ok"] },
},
],
};
}
return { ok: true };
});
const program = createCronProgram();
await program.parseAsync(["edit", "job-1", "--timeout-seconds", "12"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"cron.update",
expect.objectContaining({ timeoutSeconds: "12" }),
{
id: "job-1",
patch: {
payload: {
kind: "command",
timeoutSeconds: 12,
},
},
},
);
});
});

View File

@@ -15,6 +15,8 @@ import {
} from "./schedule-options.js";
import {
getCronChannelOptions,
parseCronCommandArgv,
parseCronCommandEnv,
parseCronToolsAllow,
parseDurationMs,
warnIfCronSchedulerDisabled,
@@ -90,12 +92,23 @@ export function registerCronEditCommand(cron: Command) {
.option("--exact", "Disable cron staggering (set stagger to 0)")
.option("--system-event <text>", "Set systemEvent payload")
.option("--message <text>", "Set agentTurn payload message")
.option("--command <shell>", "Set command payload run as sh -lc <shell> on the Gateway")
.option("--command-argv <json>", "Set command payload argv as JSON array of strings")
.option("--command-cwd <path>", "Set command payload working directory")
.option(
"--command-env <KEY=VALUE>",
"Set command payload environment overrides (repeatable)",
(value: string, previous: string[] = []) => [...previous, value],
)
.option("--command-input <text>", "Set command payload stdin")
.option(
"--thinking <level>",
"Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
)
.option("--model <model>", "Model override for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent or command jobs")
.option("--no-output-timeout-seconds <n>", "No-output timeout seconds for command jobs")
.option("--output-max-bytes <n>", "Maximum captured stdout/stderr bytes for command jobs")
.option("--light-context", "Enable lightweight bootstrap context for agent jobs")
.option("--no-light-context", "Disable lightweight bootstrap context for agent jobs")
.option("--tools <list>", "Tool allow-list (e.g. exec,read,write or exec read write)")
@@ -141,9 +154,9 @@ export function registerCronEditCommand(cron: Command) {
if (typeof opts.session === "string" && !sessionTarget) {
throw new Error("--session must be main, isolated, current, or session:<id>");
}
if (sessionTarget === "main" && opts.message) {
if (sessionTarget === "main" && (opts.message || opts.command || opts.commandArgv)) {
throw new Error(
"Main jobs cannot use --message; use --system-event or --session isolated.",
"Main jobs cannot use --message or --command; use --system-event or --session isolated.",
);
}
if (
@@ -153,7 +166,7 @@ export function registerCronEditCommand(cron: Command) {
opts.systemEvent
) {
throw new Error(
"Isolated jobs cannot use --system-event; use --message or --session main.",
"Isolated jobs cannot use --system-event; use --message, --command, or --session main.",
);
}
const hasWebhookDelivery = typeof opts.webhook === "string";
@@ -238,6 +251,13 @@ export function registerCronEditCommand(cron: Command) {
}
const hasSystemEventPatch = typeof opts.systemEvent === "string";
const commandShell = normalizeOptionalString(opts.command);
const commandArgv = parseCronCommandArgv(opts.commandArgv);
if (commandShell && commandArgv) {
throw new Error(
"Pass command payload either with --command or --command-argv, not both.",
);
}
const model = normalizeOptionalString(opts.model);
const thinking = normalizeOptionalString(opts.thinking);
const toolsAllow = parseCronToolsAllow(opts.tools);
@@ -255,6 +275,20 @@ export function registerCronEditCommand(cron: Command) {
if (rawTimeoutSeconds !== undefined && !hasTimeoutSeconds) {
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
}
const rawNoOutputTimeoutSeconds =
opts.noOutputTimeoutSeconds ??
(typeof opts.outputTimeoutSeconds === "string" ||
typeof opts.outputTimeoutSeconds === "number"
? opts.outputTimeoutSeconds
: undefined);
const noOutputTimeoutSeconds = parseStrictPositiveInteger(rawNoOutputTimeoutSeconds);
if (rawNoOutputTimeoutSeconds !== undefined && noOutputTimeoutSeconds === undefined) {
throw new Error("Invalid --no-output-timeout-seconds (must be a positive integer).");
}
const outputMaxBytes = parseStrictPositiveInteger(opts.outputMaxBytes);
if (opts.outputMaxBytes !== undefined && outputMaxBytes === undefined) {
throw new Error("Invalid --output-max-bytes (must be a positive integer).");
}
const hasDeliveryModeFlag =
opts.announce || typeof opts.deliver === "boolean" || hasWebhookDelivery;
const threadId = parseCronThreadIdOption(opts.threadId);
@@ -266,23 +300,49 @@ export function registerCronEditCommand(cron: Command) {
if (hasWebhookDelivery && (hasDeliveryTarget || hasDeliveryAccount)) {
throw new Error("--webhook cannot be combined with chat delivery options.");
}
const hasCommandSpecificPayloadField =
Boolean(commandShell) ||
Boolean(commandArgv) ||
typeof opts.commandCwd === "string" ||
typeof opts.commandInput === "string" ||
opts.commandEnv !== undefined ||
noOutputTimeoutSeconds !== undefined ||
outputMaxBytes !== undefined;
let timeoutOnlyPayloadKind: "agentTurn" | "command" | undefined;
if (
hasTimeoutSeconds &&
!hasCommandSpecificPayloadField &&
typeof opts.message !== "string" &&
!model &&
!thinking &&
typeof opts.lightContext !== "boolean" &&
typeof opts.tools !== "string" &&
!Array.isArray(opts.tools) &&
!opts.clearTools
) {
const existing = await loadCronJobForEditSchedulePatch(opts, String(id));
timeoutOnlyPayloadKind = existing?.payload.kind === "command" ? "command" : "agentTurn";
}
const hasAgentTurnPayloadField =
typeof opts.message === "string" ||
Boolean(model) ||
Boolean(thinking) ||
hasTimeoutSeconds ||
(hasTimeoutSeconds &&
!hasCommandSpecificPayloadField &&
timeoutOnlyPayloadKind !== "command") ||
typeof opts.lightContext === "boolean" ||
typeof opts.tools === "string" ||
Array.isArray(opts.tools) ||
opts.clearTools;
const hasAgentTurnPatch =
hasAgentTurnPayloadField ||
Boolean(opts.announce) ||
opts.deliver === true ||
hasDeliveryTarget ||
hasDeliveryAccount ||
(hasBestEffort && !hasWebhookDelivery);
if (hasSystemEventPatch && hasAgentTurnPatch) {
const hasCommandPayloadField =
hasCommandSpecificPayloadField ||
(hasTimeoutSeconds &&
(hasCommandSpecificPayloadField || timeoutOnlyPayloadKind === "command"));
const hasAgentTurnPatch = hasAgentTurnPayloadField;
const hasCommandPatch = hasCommandPayloadField;
if (
[hasSystemEventPatch, hasAgentTurnPatch, hasCommandPatch].filter(Boolean).length > 1
) {
throw new Error("Choose at most one payload change");
}
if (hasSystemEventPatch) {
@@ -308,6 +368,32 @@ export function registerCronEditCommand(cron: Command) {
payload.toolsAllow = toolsAllow;
}
patch.payload = payload;
} else if (hasCommandPatch) {
const payload: Record<string, unknown> = { kind: "command" };
assignIf(payload, "argv", commandArgv, Boolean(commandArgv));
assignIf(payload, "argv", ["sh", "-lc", commandShell], Boolean(commandShell));
assignIf(
payload,
"cwd",
normalizeOptionalString(opts.commandCwd),
typeof opts.commandCwd === "string",
);
assignIf(
payload,
"env",
parseCronCommandEnv(opts.commandEnv),
opts.commandEnv !== undefined,
);
assignIf(payload, "input", opts.commandInput, typeof opts.commandInput === "string");
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(
payload,
"noOutputTimeoutSeconds",
noOutputTimeoutSeconds,
noOutputTimeoutSeconds !== undefined,
);
assignIf(payload, "outputMaxBytes", outputMaxBytes, outputMaxBytes !== undefined);
patch.payload = payload;
}
if (hasDeliveryModeFlag || hasDeliveryTarget || hasDeliveryAccount || hasBestEffort) {
@@ -320,7 +406,7 @@ export function registerCronEditCommand(cron: Command) {
: "none";
} else if (
opts.bestEffortDeliver === true ||
(hasAgentTurnPayloadField && hasBestEffort)
((hasAgentTurnPayloadField || hasCommandPayloadField) && hasBestEffort)
) {
// Back-compat: best-effort true and payload edits historically implied announce mode.
delivery.mode = "announce";

View File

@@ -21,6 +21,46 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { callGatewayFromCli } from "../gateway-rpc.js";
export function parseCronCommandArgv(value: unknown): string[] | undefined {
if (typeof value !== "string") {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error("--command-argv must be a JSON array of strings");
}
if (
!Array.isArray(parsed) ||
parsed.length === 0 ||
parsed.some((entry) => typeof entry !== "string" || entry.length === 0)
) {
throw new Error("--command-argv must be a non-empty JSON array of non-empty strings");
}
return parsed;
}
export function parseCronCommandEnv(values: unknown): Record<string, string> | undefined {
const rawValues = Array.isArray(values) ? values : typeof values === "string" ? [values] : [];
if (rawValues.length === 0) {
return undefined;
}
const env: Record<string, string> = {};
for (const raw of rawValues) {
if (typeof raw !== "string") {
throw new Error("--command-env must be KEY=VALUE");
}
const idx = raw.indexOf("=");
const key = idx > 0 ? raw.slice(0, idx).trim() : "";
if (!key) {
throw new Error("--command-env must be KEY=VALUE");
}
env[key] = raw.slice(idx + 1);
}
return env;
}
export const getCronChannelOptions = () => {
// Keep help truthful even before the plugin registry is bootstrapped.
const pluginIds = listChannelPlugins()

View File

@@ -425,6 +425,23 @@ describe("maybeRepairLegacyCronStore", () => {
expectNoteContaining("Cron store migrated to SQLite", "Doctor changes");
});
it("archives legacy cron stores when an older migrated archive already exists", async () => {
const storePath = await makeTempStorePath();
await writeCronStore(storePath, [createLegacyCronJob()]);
await fs.writeFile(`${storePath}.migrated`, "old archive", "utf-8");
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter: makePrompter(true),
});
await expect(fs.stat(storePath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.readFile(`${storePath}.migrated`, "utf-8")).resolves.toBe("old archive");
await expect(fs.stat(`${storePath}.migrated.2`)).resolves.toBeTruthy();
expectNoteContaining("Cron store migrated to SQLite", "Doctor changes");
});
it("imports legacy-only jobs when SQLite already has cron rows", async () => {
const storePath = await makeTempStorePath();
await writeCurrentCronStore(storePath, [

View File

@@ -182,7 +182,9 @@ export async function maybeRepairLegacyCronStore(params: {
const legacyWebhook = normalizeOptionalString(params.cfg.cron?.webhook);
const notifyCount = rawJobs.filter((job) => job.notify === true).length;
const dreamingStaleCount = countStaleDreamingJobs(rawJobs);
const previewLines = formatLegacyIssuePreview(normalized.issues);
const previewLines = formatLegacyIssuePreview(normalized.issues, {
unresolvedAgentTurnShellToolPrompt: normalized.unresolvedAgentTurnShellToolPromptJobs,
});
if (legacyStoreDetected) {
previewLines.unshift(
legacyImportCount > 0

View File

@@ -32,9 +32,9 @@ async function archiveLegacyCronFile(filePath: string): Promise<void> {
if (!(await legacyCronFileExists(filePath))) {
return;
}
const archivePath = `${filePath}${LEGACY_CRON_ARCHIVE_SUFFIX}`;
if (await legacyCronFileExists(archivePath)) {
return;
let archivePath = `${filePath}${LEGACY_CRON_ARCHIVE_SUFFIX}`;
for (let index = 2; await legacyCronFileExists(archivePath); index += 1) {
archivePath = `${filePath}${LEGACY_CRON_ARCHIVE_SUFFIX}.${index}`;
}
await fs.rename(filePath, archivePath).catch(() => undefined);
}

View File

@@ -5,6 +5,39 @@ import {
type UnknownRecord = Record<string, unknown>;
type LegacyAgentTurnCommandPayload = {
command: string;
cwd?: string;
timeoutSeconds?: number;
};
const LEGACY_AGENT_TURN_COMMAND_MARKER_RE = /\bCommand to run\s*:/iu;
const LEGACY_AGENT_TURN_COMMAND_FIELD_RE = /^\s*-\s*(command|workdir|timeout)\s*:\s*(.*?)\s*$/iu;
const SHELL_TOOL_NAMES = new Set(["bash", "command", "exec", "process", "shell", "sh"]);
const SHELL_COMMAND_MESSAGE_RE =
/\b(?:bash|command|execute|exec|process|run|shell)\b[\s\S]{0,240}\b(?:python3?|node|bun|pnpm|npm|npx|yarn|sh|bash|sudo|cd|\.\/|\/[A-Za-z0-9._/-]+)\b/iu;
const LEGACY_DELIVERY_HINT_FIELDS = [
"deliver",
"bestEffortDeliver",
"channel",
"provider",
"to",
"threadId",
] as const;
function hasShellToolAccess(toolsAllow: unknown): boolean {
if (toolsAllow === undefined) {
return true;
}
if (!Array.isArray(toolsAllow)) {
return false;
}
return toolsAllow.some((tool) => {
const normalized = normalizeOptionalLowercaseString(tool);
return normalized === "*" || (normalized ? SHELL_TOOL_NAMES.has(normalized) : false);
});
}
function toCanonicalOpenAIModelRef(value: unknown): string | undefined {
const raw = readString(value);
if (typeof raw !== "string") {
@@ -27,6 +60,57 @@ function normalizeChannel(value: string): string {
return normalizeOptionalLowercaseString(value) ?? "";
}
function parsePositiveInteger(value: string): number | undefined {
const trimmed = value.trim();
if (!/^\d+$/u.test(trimmed)) {
return undefined;
}
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function readPositiveInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}
function parseLegacyAgentTurnCommandMessage(message: string): LegacyAgentTurnCommandPayload | null {
if (!LEGACY_AGENT_TURN_COMMAND_MARKER_RE.test(message)) {
return null;
}
let command = "";
let cwd: string | undefined;
let timeoutSeconds: number | undefined;
for (const line of message.split(/\r?\n/u)) {
const match = LEGACY_AGENT_TURN_COMMAND_FIELD_RE.exec(line);
if (!match) {
continue;
}
const key = match[1]?.toLowerCase();
const value = match[2]?.trim() ?? "";
if (key === "command" && value && !command) {
command = value;
} else if (key === "workdir" && value && !cwd) {
cwd = value;
} else if (key === "timeout" && value && timeoutSeconds === undefined) {
timeoutSeconds = parsePositiveInteger(value);
}
}
if (!command) {
return null;
}
return {
command,
...(cwd ? { cwd } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
};
}
export function hasLegacyOpenAICodexCronModelRef(payload: UnknownRecord): boolean {
if (toCanonicalOpenAIModelRef(payload.model)) {
return true;
@@ -89,3 +173,58 @@ export function migrateLegacyCronPayload(payload: UnknownRecord): boolean {
return mutated;
}
export function migrateLegacyAgentTurnCommandPayload(payload: UnknownRecord): boolean {
if (payload.kind !== "agentTurn") {
return false;
}
const message = readString(payload.message);
if (typeof message !== "string") {
return false;
}
const parsed = parseLegacyAgentTurnCommandMessage(message);
if (!parsed) {
return false;
}
if (!hasShellToolAccess(payload.toolsAllow)) {
return false;
}
const timeoutSeconds = readPositiveInteger(payload.timeoutSeconds) ?? parsed.timeoutSeconds;
const deliveryHints: UnknownRecord = {};
for (const key of LEGACY_DELIVERY_HINT_FIELDS) {
if (key in payload) {
deliveryHints[key] = payload[key];
}
}
for (const key of Object.keys(payload)) {
delete payload[key];
}
payload.kind = "command";
payload.argv = ["sh", "-lc", parsed.command];
if (parsed.cwd) {
payload.cwd = parsed.cwd;
}
if (timeoutSeconds !== undefined) {
payload.timeoutSeconds = timeoutSeconds;
}
Object.assign(payload, deliveryHints);
return true;
}
export function hasUnresolvedAgentTurnShellToolPrompt(payload: UnknownRecord): boolean {
if (payload.kind !== "agentTurn") {
return false;
}
const message = readString(payload.message);
if (typeof message !== "string") {
return false;
}
const parsed = parseLegacyAgentTurnCommandMessage(message);
return (
Boolean(parsed) ||
(hasShellToolAccess(payload.toolsAllow) && SHELL_COMMAND_MESSAGE_RE.test(message))
);
}

View File

@@ -4,12 +4,27 @@ import { normalizeCronJobInput } from "../../../cron/normalize.js";
import type { CronJob } from "../../../cron/types.js";
export type CronLegacyIssueCounts = Partial<Record<string, number>>;
export type CronLegacyIssueDetails = {
unresolvedAgentTurnShellToolPrompt?: string[];
};
function pluralize(count: number, noun: string) {
return `${count} ${noun}${count === 1 ? "" : "s"}`;
}
export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[] {
function formatJobNameList(names: string[] | undefined): string {
if (!names || names.length === 0) {
return "";
}
const preview = names.slice(0, 5).map((name) => `\`${name}\``);
const remaining = names.length - preview.length;
return remaining > 0 ? `: ${preview.join(", ")} (+${remaining} more)` : `: ${preview.join(", ")}`;
}
export function formatLegacyIssuePreview(
issues: CronLegacyIssueCounts,
details: CronLegacyIssueDetails = {},
): string[] {
const lines: string[] = [];
if (issues.jobId) {
lines.push(`- ${pluralize(issues.jobId, "job")} still uses legacy \`jobId\``);
@@ -36,6 +51,16 @@ export function formatLegacyIssuePreview(issues: CronLegacyIssueCounts): string[
`- ${pluralize(issues.legacyPayloadCodexModel, "job")} still uses legacy \`openai-codex/*\` cron model refs`,
);
}
if (issues.legacyAgentTurnCommandPayload) {
lines.push(
`- ${pluralize(issues.legacyAgentTurnCommandPayload, "job")} uses an agent prompt to run a shell command`,
);
}
if (issues.unresolvedAgentTurnShellToolPrompt) {
lines.push(
`- ${pluralize(issues.unresolvedAgentTurnShellToolPrompt, "job")} asks an isolated agent for shell/process tools and needs manual command conversion${formatJobNameList(details.unresolvedAgentTurnShellToolPrompt)}`,
);
}
if (issues.legacyPayloadProvider) {
lines.push(
`- ${pluralize(issues.legacyPayloadProvider, "job")} still uses payload \`provider\` as a delivery alias`,

View File

@@ -121,6 +121,165 @@ describe("normalizeStoredCronJobs", () => {
expect(payload.fallbacks).toEqual(["anthropic/claude-opus-4.6", "openai/gpt-5.4-mini"]);
});
it("converts legacy agent command prompts into command cron payloads", () => {
const command =
"cd /home/openclaw/.razor/quant && ./scripts/system/run_position_control.sh --write-card --silent-token NO_REPLY";
const { job, result } = normalizeOneJob(
makeLegacyJob({
id: "quant-position-card",
schedule: { kind: "cron", expr: "*/30 * * * *", tz: "Europe/Madrid" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: [
"Run this deterministic shell job once and report only the final JSON/status.",
"",
"Command to run:",
`- command: ${command}`,
"- workdir: /home/openclaw/.razor/quant",
"- background: false",
"- timeout: 840",
"",
"Final response contract:",
"- If the command prints exactly NO_REPLY, respond exactly NO_REPLY.",
"- Otherwise return the concise command output.",
].join("\n"),
toolsAllow: ["bash", "process"],
lightContext: true,
timeoutSeconds: 900,
model: "openai/gpt-5.5",
deliver: true,
channel: "telegram",
to: "123",
},
}),
);
expect(result.mutated).toBe(true);
expect(result.issues.legacyAgentTurnCommandPayload).toBe(1);
expect(job.delivery).toEqual({ mode: "announce", channel: "telegram", to: "123" });
const payload = job.payload as Record<string, unknown>;
expect(payload).toEqual({
kind: "command",
argv: ["sh", "-lc", command],
cwd: "/home/openclaw/.razor/quant",
timeoutSeconds: 900,
});
});
it("does not convert command-shaped prompts without shell tool access", () => {
const command = "python3 scripts/check_mail.py";
const { job, result } = normalizeOneJob(
makeLegacyJob({
id: "restricted-command-prompt",
schedule: { kind: "cron", expr: "*/30 * * * *", tz: "Europe/Madrid" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: [
"Command to run:",
`- command: ${command}`,
"- workdir: /home/openclaw/.razor/clawd",
].join("\n"),
toolsAllow: ["read", "message"],
},
}),
);
expect(result.issues.legacyAgentTurnCommandPayload).toBeUndefined();
expect(result.issues.unresolvedAgentTurnShellToolPrompt).toBe(1);
const payload = job.payload as Record<string, unknown>;
expect(payload.kind).toBe("agentTurn");
expect(payload.message).toContain(command);
expect(payload.toolsAllow).toEqual(["read", "message"]);
});
it("warns without converting mixed agent prompts that request shell tools", () => {
const { job, result } = normalizeOneJob(
makeLegacyJob({
id: "mixed-agent-job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Europe/Madrid" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message:
"Run deterministic health first: python3 scripts/check_mail.py and then decide whether to send a summary.",
toolsAllow: ["bash", "read", "message"],
lightContext: true,
},
}),
);
expect(result.issues.legacyAgentTurnCommandPayload).toBeUndefined();
expect(result.issues.unresolvedAgentTurnShellToolPrompt).toBe(1);
expect(result.unresolvedAgentTurnShellToolPromptJobs).toEqual(["Legacy job"]);
const payload = job.payload as Record<string, unknown>;
expect(payload.kind).toBe("agentTurn");
expect(payload.message).toContain("Run deterministic health first");
expect(payload.toolsAllow).toEqual(["bash", "read", "message"]);
});
it("warns on shell-style prompts with unrestricted tool access", () => {
const { result } = normalizeOneJob(
makeLegacyJob({
id: "implicit-tools-shell-job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Europe/Madrid" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message:
"Run python3 scripts/check_mail.py and send a compact summary if anything changed.",
lightContext: true,
},
}),
);
expect(result.issues.unresolvedAgentTurnShellToolPrompt).toBe(1);
expect(result.unresolvedAgentTurnShellToolPromptJobs).toEqual(["Legacy job"]);
});
it("warns on shell-style prompts with wildcard tool access", () => {
const { result } = normalizeOneJob(
makeLegacyJob({
id: "wildcard-tools-shell-job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Europe/Madrid" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message:
"Execute ./scripts/check_mail.sh and send a compact summary if anything changed.",
toolsAllow: ["*"],
lightContext: true,
},
}),
);
expect(result.issues.unresolvedAgentTurnShellToolPrompt).toBe(1);
expect(result.unresolvedAgentTurnShellToolPromptJobs).toEqual(["Legacy job"]);
});
it("does not warn on ordinary agent prompts that mention commands without shell tools", () => {
const { job, result } = normalizeOneJob(
makeLegacyJob({
id: "ordinary-agent-job",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Europe/Madrid" },
sessionTarget: "isolated",
payload: {
kind: "agentTurn",
message: "Explain whether the user should run python3 scripts/check_mail.py.",
toolsAllow: ["read", "message"],
lightContext: true,
},
delivery: { mode: "announce" },
}),
);
expect(result.issues.unresolvedAgentTurnShellToolPrompt).toBeUndefined();
const payload = job.payload as Record<string, unknown>;
expect(payload.kind).toBe("agentTurn");
expect(payload.message).toContain("python3 scripts/check_mail.py");
});
it("does not report legacyPayloadKind for already-normalized payload kinds", () => {
const jobs = [
{

View File

@@ -12,7 +12,12 @@ import { coerceFiniteScheduleNumber } from "../../../cron/schedule.js";
import { inferCronJobName } from "../../../cron/service/normalize.js";
import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../../../cron/stagger.js";
import { normalizeLegacyDeliveryInput } from "./legacy-delivery.js";
import { hasLegacyOpenAICodexCronModelRef, migrateLegacyCronPayload } from "./payload-migration.js";
import {
hasUnresolvedAgentTurnShellToolPrompt,
hasLegacyOpenAICodexCronModelRef,
migrateLegacyAgentTurnCommandPayload,
migrateLegacyCronPayload,
} from "./payload-migration.js";
type CronStoreIssueKey =
| "jobId"
@@ -22,6 +27,8 @@ type CronStoreIssueKey =
| "legacyScheduleCron"
| "legacyPayloadKind"
| "legacyPayloadCodexModel"
| "legacyAgentTurnCommandPayload"
| "unresolvedAgentTurnShellToolPrompt"
| "legacyPayloadProvider"
| "legacyTopLevelPayloadFields"
| "legacyTopLevelDeliveryFields"
@@ -33,6 +40,7 @@ type CronStoreIssues = Partial<Record<CronStoreIssueKey, number>>;
type NormalizeCronStoreJobsResult = {
issues: CronStoreIssues;
unresolvedAgentTurnShellToolPromptJobs: string[];
jobs: Array<Record<string, unknown>>;
mutated: boolean;
removedJobs: Array<{ job: Record<string, unknown>; reason: string; sourceIndex: number }>;
@@ -236,6 +244,7 @@ export function normalizeStoredCronJobs(
jobs: Array<Record<string, unknown>>,
): NormalizeCronStoreJobsResult {
const issues: CronStoreIssues = {};
const unresolvedAgentTurnShellToolPromptJobs: string[] = [];
let mutated = false;
const keptJobs: Array<Record<string, unknown>> = [];
const removedJobs: NormalizeCronStoreJobsResult["removedJobs"] = [];
@@ -407,6 +416,16 @@ export function normalizeStoredCronJobs(
trackIssue("legacyPayloadProvider");
}
}
if (migrateLegacyAgentTurnCommandPayload(payloadRecord)) {
mutated = true;
trackIssue("legacyAgentTurnCommandPayload");
} else if (hasUnresolvedAgentTurnShellToolPrompt(payloadRecord)) {
trackIssue("unresolvedAgentTurnShellToolPrompt");
const name = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id);
if (name) {
unresolvedAgentTurnShellToolPromptJobs.push(name);
}
}
}
const schedule = raw.schedule;
@@ -543,7 +562,8 @@ export function normalizeStoredCronJobs(
mutated = true;
}
} else {
const inferredSessionTarget = payloadKind === "agentTurn" ? "isolated" : "main";
const inferredSessionTarget =
payloadKind === "agentTurn" || payloadKind === "command" ? "isolated" : "main";
if (raw.sessionTarget !== inferredSessionTarget) {
raw.sessionTarget = inferredSessionTarget;
mutated = true;
@@ -551,18 +571,18 @@ export function normalizeStoredCronJobs(
}
const sessionTarget = normalizeOptionalLowercaseString(raw.sessionTarget) ?? "";
const isIsolatedAgentTurn =
const isIsolatedRunnablePayload =
sessionTarget === "isolated" ||
sessionTarget === "current" ||
sessionTarget.startsWith("session:") ||
(sessionTarget === "" && payloadKind === "agentTurn");
(sessionTarget === "" && (payloadKind === "agentTurn" || payloadKind === "command"));
const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery);
const normalizedLegacy = normalizeLegacyDeliveryInput({
delivery: hasDelivery ? (delivery as Record<string, unknown>) : null,
payload: payloadRecord,
});
if (isIsolatedAgentTurn && payloadKind === "agentTurn") {
if (isIsolatedRunnablePayload && (payloadKind === "agentTurn" || payloadKind === "command")) {
if (!hasDelivery && normalizedLegacy.delivery) {
raw.delivery = normalizedLegacy.delivery;
mutated = true;
@@ -604,5 +624,5 @@ export function normalizeStoredCronJobs(
jobs.splice(0, jobs.length, ...keptJobs);
}
return { issues, jobs, mutated, removedJobs };
return { issues, unresolvedAgentTurnShellToolPromptJobs, jobs, mutated, removedJobs };
}

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { runCronCommandJob } from "./command-runner.js";
import type { CronJob } from "./types.js";
function makeCommandJob(payload: Extract<CronJob["payload"], { kind: "command" }>): CronJob {
const now = Date.now();
return {
id: "command-job",
name: "Command job",
enabled: true,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload,
state: {},
};
}
describe("runCronCommandJob", () => {
it("runs command argv and returns stdout as the deliverable summary", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "process.stdout.write('hello from cron')"],
timeoutSeconds: 5,
}),
nowMs: () => 123,
});
expect(result.status).toBe("ok");
expect(result.summary).toBe("hello from cron");
expect(result.diagnostics?.entries[0]).toMatchObject({
ts: 123,
source: "exec",
severity: "info",
exitCode: 0,
});
});
it("preserves exact NO_REPLY stdout for outbound suppression", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "process.stdout.write('NO_REPLY\\n')"],
timeoutSeconds: 5,
}),
});
expect(result.status).toBe("ok");
expect(result.summary).toBe("NO_REPLY");
});
it("marks non-zero exit codes as cron errors and keeps stderr as summary", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "process.stderr.write('bad thing'); process.exit(7)"],
timeoutSeconds: 5,
}),
});
expect(result.status).toBe("error");
expect(result.error).toBe("command exited with code 7");
expect(result.summary).toBe("bad thing");
expect(result.diagnostics?.entries[0]).toMatchObject({
source: "exec",
severity: "error",
exitCode: 7,
});
});
it("marks command timeouts as cron errors", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "setInterval(() => {}, 1000)"],
timeoutSeconds: 0.05,
}),
nowMs: () => 456,
});
expect(result.status).toBe("error");
expect(result.error).toBe("command timed out");
expect(result.diagnostics?.entries[0]).toMatchObject({
ts: 456,
source: "exec",
severity: "error",
});
});
it("marks no-output timeouts as cron errors", async () => {
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "setInterval(() => {}, 1000)"],
timeoutSeconds: 5,
noOutputTimeoutSeconds: 0.05,
}),
});
expect(result.status).toBe("error");
expect(result.error).toBe("command produced no output before noOutputTimeoutSeconds");
expect(result.diagnostics?.entries[0]).toMatchObject({
source: "exec",
severity: "error",
});
});
it("marks aborted command runs as cron errors", async () => {
const controller = new AbortController();
controller.abort();
const result = await runCronCommandJob({
job: makeCommandJob({
kind: "command",
argv: [process.execPath, "-e", "process.stdout.write('should not run')"],
timeoutSeconds: 5,
}),
abortSignal: controller.signal,
});
expect(result.status).toBe("error");
expect(result.error).toBe("command stopped");
expect(result.summary).toBeUndefined();
});
});

169
src/cron/command-runner.ts Normal file
View File

@@ -0,0 +1,169 @@
import { finiteSecondsToTimerSafeMilliseconds } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { runCommandWithTimeout } from "../process/exec.js";
import type { CronRunDiagnostics, CronRunOutcome, CronRunStatus, CronJob } from "./types.js";
const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60_000;
const EFFECTIVELY_UNBOUNDED_TIMEOUT_MS = 2_147_483_647;
function secondsToMs(value: number | undefined): number | undefined {
if (typeof value !== "number") {
return undefined;
}
if (value <= 0) {
return EFFECTIVELY_UNBOUNDED_TIMEOUT_MS;
}
return finiteSecondsToTimerSafeMilliseconds(value) ?? undefined;
}
function formatCommand(argv: string[]): string {
return argv.map((arg) => JSON.stringify(arg)).join(" ");
}
function trimOutput(value: string): string | undefined {
return normalizeOptionalString(value);
}
function buildCommandSummary(params: { stdout: string; stderr: string }): string | undefined {
const stdout = trimOutput(params.stdout);
const stderr = trimOutput(params.stderr);
if (stdout && stderr) {
return `stdout:\n${stdout}\n\nstderr:\n${stderr}`;
}
return stdout ?? stderr;
}
function commandErrorMessage(params: {
code: number | null;
signal: NodeJS.Signals | null;
termination: string;
}): string {
if (params.termination === "timeout") {
return "command timed out";
}
if (params.termination === "no-output-timeout") {
return "command produced no output before noOutputTimeoutSeconds";
}
if (params.termination === "signal") {
return params.signal ? `command stopped by signal ${params.signal}` : "command stopped";
}
if (typeof params.code === "number") {
return `command exited with code ${params.code}`;
}
return "command failed";
}
function buildDiagnostics(params: {
command: string;
status: CronRunStatus;
summary?: string;
code: number | null;
signal: NodeJS.Signals | null;
stdoutTruncatedBytes?: number;
stderrTruncatedBytes?: number;
nowMs: () => number;
}): CronRunDiagnostics {
const truncated =
Boolean(params.stdoutTruncatedBytes && params.stdoutTruncatedBytes > 0) ||
Boolean(params.stderrTruncatedBytes && params.stderrTruncatedBytes > 0);
return {
...(params.summary ? { summary: params.summary } : {}),
entries: [
{
ts: params.nowMs(),
source: "exec",
severity: params.status === "ok" ? "info" : "error",
message: params.summary
? `command ${params.status}: ${params.command}`
: `command ${params.status} with no output: ${params.command}`,
exitCode: params.code,
truncated,
...(params.signal ? { toolName: `signal:${params.signal}` } : {}),
},
],
};
}
/** Executes a cron command payload without starting an agent/model run. */
export async function runCronCommandJob(params: {
job: CronJob;
abortSignal?: AbortSignal;
nowMs?: () => number;
}): Promise<CronRunOutcome> {
const nowMs = params.nowMs ?? Date.now;
const { payload } = params.job;
if (payload.kind !== "command") {
return {
status: "skipped",
error: 'command runner requires payload.kind="command"',
};
}
if (!Array.isArray(payload.argv) || payload.argv.length === 0) {
return {
status: "skipped",
error: 'command payload requires non-empty "argv"',
};
}
const command = formatCommand(payload.argv);
const noOutputTimeoutMs = secondsToMs(payload.noOutputTimeoutSeconds);
try {
const result = await runCommandWithTimeout(payload.argv, {
timeoutMs: secondsToMs(payload.timeoutSeconds) ?? DEFAULT_COMMAND_TIMEOUT_MS,
...(payload.cwd ? { cwd: payload.cwd } : {}),
...(payload.input !== undefined ? { input: payload.input } : {}),
...(payload.env ? { env: payload.env } : {}),
...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}),
...(payload.outputMaxBytes !== undefined ? { maxOutputBytes: payload.outputMaxBytes } : {}),
...(params.abortSignal ? { signal: params.abortSignal } : {}),
});
const ok =
result.code === 0 &&
!result.killed &&
result.termination !== "timeout" &&
result.termination !== "no-output-timeout" &&
result.termination !== "signal";
const status: CronRunStatus = ok ? "ok" : "error";
const summary = buildCommandSummary({ stdout: result.stdout, stderr: result.stderr });
const error = ok
? undefined
: commandErrorMessage({
code: result.code,
signal: result.signal,
termination: result.termination,
});
return {
status,
...(error ? { error } : {}),
...(summary ? { summary } : {}),
diagnostics: buildDiagnostics({
command,
status,
summary,
code: result.code,
signal: result.signal,
stdoutTruncatedBytes: result.stdoutTruncatedBytes,
stderrTruncatedBytes: result.stderrTruncatedBytes,
nowMs,
}),
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return {
status: "error",
error,
diagnostics: {
summary: error,
entries: [
{
ts: nowMs(),
source: "exec",
severity: "error",
message: `command failed to start: ${command}: ${error}`,
exitCode: null,
},
],
},
};
}
}

View File

@@ -95,13 +95,13 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
};
}
const isIsolatedAgentTurn =
job.payload.kind === "agentTurn" &&
const isDetachedOutputJob =
(job.payload.kind === "agentTurn" || job.payload.kind === "command") &&
typeof job.sessionTarget === "string" &&
(job.sessionTarget === "isolated" ||
job.sessionTarget === "current" ||
job.sessionTarget.startsWith("session:"));
const resolvedMode = isIsolatedAgentTurn ? "announce" : "none";
const resolvedMode = isDetachedOutputJob ? "announce" : "none";
return {
mode: resolvedMode,

View File

@@ -360,6 +360,52 @@ describe("normalizeCronJobCreate", () => {
expect(delivery.mode).toBe("announce");
});
it("defaults command payloads to isolated announce jobs", () => {
const normalized = normalizeCronJobCreate({
name: "command default",
schedule: { kind: "every", everyMs: 60_000 },
payload: {
kind: "command",
argv: ["sh", "-lc", "echo ok"],
cwd: " /srv/example ",
env: { FOO: "bar" },
timeoutSeconds: 30,
noOutputTimeoutSeconds: 5,
outputMaxBytes: 4096,
},
}) as unknown as Record<string, unknown>;
expect(normalized.sessionTarget).toBe("isolated");
expect((normalized.delivery as Record<string, unknown>).mode).toBe("announce");
expect(normalized.payload).toEqual({
kind: "command",
argv: ["sh", "-lc", "echo ok"],
cwd: "/srv/example",
env: { FOO: "bar" },
timeoutSeconds: 30,
noOutputTimeoutSeconds: 5,
outputMaxBytes: 4096,
});
expect(validateCronAddParams(normalized)).toBe(true);
});
it("preserves command argv argument bytes", () => {
const normalized = normalizeCronJobCreate({
name: "command exact argv",
schedule: { kind: "every", everyMs: 60_000 },
payload: {
kind: "command",
argv: ["printf", "%s", " padded value "],
},
}) as unknown as Record<string, unknown>;
expect(normalized.payload).toMatchObject({
kind: "command",
argv: ["printf", "%s", " padded value "],
});
expect(validateCronAddParams(normalized)).toBe(true);
});
it("preserves timeoutSeconds=0 for no-timeout agentTurn payloads", () => {
const normalized = normalizeCronJobCreate({
name: "no-timeout",

View File

@@ -52,6 +52,32 @@ function normalizeTrimmedStringArray(
return undefined;
}
function normalizeTrimmedStringRecord(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const entries: Array<[string, string]> = [];
for (const [rawKey, rawValue] of Object.entries(value)) {
const key = normalizeOptionalString(rawKey);
const val = typeof rawValue === "string" ? rawValue : undefined;
if (!key || val === undefined) {
return undefined;
}
entries.push([key, val]);
}
return Object.fromEntries(entries);
}
function normalizeCommandArgv(value: unknown): string[] | undefined {
if (!Array.isArray(value) || value.length === 0) {
return undefined;
}
if (value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
return undefined;
}
return [...value];
}
function coerceSchedule(schedule: UnknownRecord) {
const next: UnknownRecord = { ...schedule };
const rawKind = normalizeLowercaseStringOrEmpty(schedule.kind);
@@ -121,6 +147,8 @@ function coercePayload(payload: UnknownRecord) {
next.kind = "agentTurn";
} else if (kindRaw === "systemevent") {
next.kind = "systemEvent";
} else if (kindRaw === "command") {
next.kind = "command";
} else if (kindRaw) {
next.kind = kindRaw;
}
@@ -180,6 +208,52 @@ function coercePayload(payload: UnknownRecord) {
delete next.toolsAllow;
}
}
if ("argv" in next) {
const argv = normalizeCommandArgv(next.argv);
if (Array.isArray(argv) && argv.length > 0) {
next.argv = argv;
} else {
delete next.argv;
}
}
if ("cwd" in next) {
const cwd = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.cwd);
if (cwd !== undefined) {
next.cwd = cwd;
} else {
delete next.cwd;
}
}
if ("env" in next) {
const env = normalizeTrimmedStringRecord(next.env);
if (env !== undefined) {
next.env = env;
} else {
delete next.env;
}
}
if ("input" in next && typeof next.input !== "string") {
delete next.input;
}
if ("noOutputTimeoutSeconds" in next) {
const noOutputTimeoutSeconds = parseOptionalField(
TimeoutSecondsFieldSchema,
next.noOutputTimeoutSeconds,
);
if (noOutputTimeoutSeconds !== undefined) {
next.noOutputTimeoutSeconds = noOutputTimeoutSeconds;
} else {
delete next.noOutputTimeoutSeconds;
}
}
if ("outputMaxBytes" in next) {
const outputMaxBytes = parseOptionalField(TimeoutSecondsFieldSchema, next.outputMaxBytes);
if (outputMaxBytes !== undefined && outputMaxBytes > 0) {
next.outputMaxBytes = Math.floor(outputMaxBytes);
} else {
delete next.outputMaxBytes;
}
}
if (
"allowUnsafeExternalContent" in next &&
typeof next.allowUnsafeExternalContent !== "boolean"
@@ -195,8 +269,29 @@ function coercePayload(payload: UnknownRecord) {
delete next.lightContext;
delete next.allowUnsafeExternalContent;
delete next.toolsAllow;
delete next.argv;
delete next.cwd;
delete next.env;
delete next.input;
delete next.noOutputTimeoutSeconds;
delete next.outputMaxBytes;
} else if (next.kind === "agentTurn") {
delete next.text;
delete next.argv;
delete next.cwd;
delete next.env;
delete next.input;
delete next.noOutputTimeoutSeconds;
delete next.outputMaxBytes;
} else if (next.kind === "command") {
delete next.text;
delete next.message;
delete next.model;
delete next.fallbacks;
delete next.thinking;
delete next.lightContext;
delete next.allowUnsafeExternalContent;
delete next.toolsAllow;
}
return next;
}
@@ -462,7 +557,12 @@ export function normalizeCronJobInput(
) {
next.name = inferCronJobName({
schedule: next.schedule as { kind?: unknown; everyMs?: unknown; expr?: unknown },
payload: next.payload as { kind?: unknown; text?: unknown; message?: unknown },
payload: next.payload as {
kind?: unknown;
text?: unknown;
message?: unknown;
argv?: unknown;
},
});
} else if (typeof next.name === "string") {
const trimmed = next.name.trim();
@@ -476,7 +576,7 @@ export function normalizeCronJobInput(
// turns isolate by default to avoid unbounded token accumulation.
if (kind === "systemEvent") {
next.sessionTarget = "main";
} else if (kind === "agentTurn") {
} else if (kind === "agentTurn" || kind === "command") {
next.sessionTarget = "isolated";
}
}
@@ -516,13 +616,17 @@ export function normalizeCronJobInput(
const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : "";
// Resolved "current" and custom session ids still use isolated-agent
// delivery semantics, so they get the same default announce behavior.
const isIsolatedAgentTurn =
const isDetachedDeliveryJob =
sessionTarget === "isolated" ||
sessionTarget === "current" ||
sessionTarget.startsWith("session:") ||
(sessionTarget === "" && payloadKind === "agentTurn");
(sessionTarget === "" && (payloadKind === "agentTurn" || payloadKind === "command"));
const hasDelivery = "delivery" in next && next.delivery !== undefined;
if (!hasDelivery && isIsolatedAgentTurn && payloadKind === "agentTurn") {
if (
!hasDelivery &&
isDetachedDeliveryJob &&
(payloadKind === "agentTurn" || payloadKind === "command")
) {
next.delivery = { mode: "announce" };
}
}

View File

@@ -57,7 +57,7 @@ export function getInvalidPersistedCronJobReason(
}
const payloadRecord = payload as Record<string, unknown>;
const payloadKind = payloadRecord.kind;
if (payloadKind !== "systemEvent" && payloadKind !== "agentTurn") {
if (payloadKind !== "systemEvent" && payloadKind !== "agentTurn" && payloadKind !== "command") {
return "invalid-payload";
}
if (payloadKind === "systemEvent") {
@@ -72,5 +72,15 @@ export function getInvalidPersistedCronJobReason(
return "invalid-payload";
}
}
if (payloadKind === "command") {
const argv = payloadRecord.argv;
if (
!Array.isArray(argv) ||
argv.length === 0 ||
argv.some((value) => typeof value !== "string" || value.length === 0)
) {
return "invalid-payload";
}
}
return null;
}

View File

@@ -5,7 +5,10 @@ export function resolveInitialCronDelivery(input: CronJobCreate): CronDelivery |
if (input.delivery) {
return input.delivery;
}
if (input.sessionTarget === "isolated" && input.payload.kind === "agentTurn") {
if (
input.sessionTarget === "isolated" &&
(input.payload.kind === "agentTurn" || input.payload.kind === "command")
) {
return { mode: "announce" };
}
return undefined;

View File

@@ -285,8 +285,10 @@ export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "pay
if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") {
throw new Error('main cron jobs require payload.kind="systemEvent"');
}
if (isIsolatedLike && job.payload.kind !== "agentTurn") {
throw new Error('isolated/current/session cron jobs require payload.kind="agentTurn"');
if (isIsolatedLike && job.payload.kind !== "agentTurn" && job.payload.kind !== "command") {
throw new Error(
'isolated/current/session cron jobs require payload.kind="agentTurn" or "command"',
);
}
}
@@ -878,6 +880,35 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
return { kind: "systemEvent", text };
}
if (patch.kind === "command") {
if (existing.kind !== "command") {
return buildPayloadFromPatch(patch);
}
const next: Extract<CronPayload, { kind: "command" }> = { ...existing };
if (Array.isArray(patch.argv)) {
next.argv = patch.argv;
}
if (typeof patch.cwd === "string") {
next.cwd = patch.cwd;
}
if (patch.env && typeof patch.env === "object" && !Array.isArray(patch.env)) {
next.env = patch.env;
}
if (typeof patch.input === "string") {
next.input = patch.input;
}
if (typeof patch.timeoutSeconds === "number") {
next.timeoutSeconds = patch.timeoutSeconds;
}
if (typeof patch.noOutputTimeoutSeconds === "number") {
next.noOutputTimeoutSeconds = patch.noOutputTimeoutSeconds;
}
if (typeof patch.outputMaxBytes === "number") {
next.outputMaxBytes = patch.outputMaxBytes;
}
return next;
}
if (existing.kind !== "agentTurn") {
return buildPayloadFromPatch(patch);
}
@@ -920,6 +951,22 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
return { kind: "systemEvent", text: patch.text };
}
if (patch.kind === "command") {
if (!Array.isArray(patch.argv) || patch.argv.length === 0) {
throw new Error('cron.update payload.kind="command" requires argv');
}
return {
kind: "command",
argv: patch.argv,
cwd: patch.cwd,
env: patch.env,
input: patch.input,
timeoutSeconds: patch.timeoutSeconds,
noOutputTimeoutSeconds: patch.noOutputTimeoutSeconds,
outputMaxBytes: patch.outputMaxBytes,
};
}
if (typeof patch.message !== "string" || patch.message.length === 0) {
throw new Error('cron.update payload.kind="agentTurn" requires message');
}

View File

@@ -34,14 +34,16 @@ export function normalizeOptionalAgentId(raw: unknown) {
/** Infers a compact cron job name from payload text first, then schedule shape. */
export function inferCronJobName(job: {
schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown };
payload?: { kind?: unknown; text?: unknown; message?: unknown };
payload?: { kind?: unknown; text?: unknown; message?: unknown; argv?: unknown };
}) {
const text =
job?.payload?.kind === "systemEvent" && typeof job.payload.text === "string"
? job.payload.text
: job?.payload?.kind === "agentTurn" && typeof job.payload.message === "string"
? job.payload.message
: "";
: job?.payload?.kind === "command" && Array.isArray(job.payload.argv)
? job.payload.argv.join(" ")
: "";
const firstLine =
text
.split("\n")
@@ -71,5 +73,7 @@ export function normalizePayloadToSystemText(payload: CronPayload) {
if (payload.kind === "systemEvent") {
return typeof payload.text === "string" ? payload.text.trim() : "";
}
return typeof payload.message === "string" ? payload.message.trim() : "";
return payload.kind === "agentTurn" && typeof payload.message === "string"
? payload.message.trim()
: "";
}

View File

@@ -134,6 +134,13 @@ export type CronServiceDeps = {
} & CronRunOutcome &
CronRunTelemetry
>;
runCommandJob?: (params: { job: CronJob; abortSignal?: AbortSignal }) => Promise<
{
delivered?: boolean;
deliveryAttempted?: boolean;
delivery?: CronDeliveryTrace;
} & CronRunOutcome
>;
cleanupTimedOutAgentRun?: (params: {
job: CronJob;
timeoutMs: number;

View File

@@ -14,10 +14,11 @@ export const DEFAULT_JOB_TIMEOUT_MS = 10 * 60_000; // 10 minutes
*/
export const AGENT_TURN_SAFETY_TIMEOUT_MS = 60 * 60_000; // 60 minutes
/** Resolves the wall-clock timeout for a cron job, including explicit agent-turn overrides. */
/** Resolves the wall-clock timeout for a cron job, including explicit detached-run overrides. */
export function resolveCronJobTimeoutMs(job: CronJob): number | undefined {
const configuredTimeoutMs =
job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number"
(job.payload.kind === "agentTurn" || job.payload.kind === "command") &&
typeof job.payload.timeoutSeconds === "number"
? (finiteSecondsToTimerSafeMilliseconds(job.payload.timeoutSeconds) ?? 0)
: undefined;
if (configuredTimeoutMs === undefined) {

View File

@@ -46,6 +46,22 @@ function createDueIsolatedAgentJob(params: { now: number }): CronJob {
};
}
function createDueCommandJob(params: { now: number }): CronJob {
return {
id: "command-job",
agentId: "finn",
name: "command job",
enabled: true,
createdAtMs: params.now - 60_000,
updatedAtMs: params.now - 60_000,
schedule: { kind: "every", everyMs: 60_000, anchorMs: params.now - 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "command", argv: ["sh", "-lc", "echo ok"] },
state: { nextRunAtMs: params.now - 1 },
};
}
afterEach(() => {
resetTaskRegistryForTests();
});
@@ -183,6 +199,36 @@ describe("cron service timer seam coverage", () => {
timeoutSpy.mockRestore();
});
it("runs command cron jobs without isolated agent setup", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-03-23T12:00:00.000Z");
const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const }));
const runCommandJob = vi.fn(async () => ({
status: "ok" as const,
summary: "command ok",
}));
const state = createCronServiceState({
storePath,
cronEnabled: true,
log: logger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeat: vi.fn(),
runIsolatedAgentJob,
runCommandJob,
});
const job = createDueCommandJob({ now });
const result = await executeJobCore(state, job);
expect(result).toMatchObject({ status: "ok", summary: "command ok" });
expect(runCommandJob).toHaveBeenCalledWith({
job,
abortSignal: undefined,
});
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
});
it("records isolated cron task runs against the backing cron session", async () => {
const { storePath } = await makeStorePath();
const now = Date.parse("2026-03-23T12:00:00.000Z");

View File

@@ -1509,8 +1509,45 @@ async function executeDetachedCronJob(
delivery?: CronDeliveryTrace;
}
> {
if (job.payload.kind === "command") {
if (!state.deps.runCommandJob) {
const error = "cron command runner is not configured";
return {
status: "skipped",
error,
diagnostics: createCronRunDiagnosticsFromError("cron-preflight", error, {
severity: "warn",
nowMs: state.deps.nowMs,
}),
};
}
const res = await state.deps.runCommandJob({
job,
abortSignal,
});
if (abortSignal?.aborted) {
const error = abortErrorMessage(abortSignal);
return {
status: "error",
error,
diagnostics: createCronRunDiagnosticsFromError("cron-setup", error, {
nowMs: state.deps.nowMs,
}),
};
}
return {
status: res.status,
error: res.error,
summary: res.summary,
delivered: res.delivered,
deliveryAttempted: res.deliveryAttempted,
delivery: res.delivery,
diagnostics: res.diagnostics,
};
}
if (job.payload.kind !== "agentTurn") {
const error = "isolated job requires payload.kind=agentTurn";
const error = 'isolated job requires payload.kind="agentTurn" or "command"';
return {
status: "skipped",
error,

View File

@@ -521,6 +521,35 @@ describe("cron store", () => {
});
});
it("round-trips command payloads through SQLite", async () => {
const store = await makeStorePath();
const payload = makeStore("command-job", true);
payload.jobs[0].sessionTarget = "isolated";
payload.jobs[0].payload = {
kind: "command",
argv: ["sh", "-lc", 'printf %s "$1"', " "],
cwd: "/srv/example",
env: { FOO: "bar" },
input: "stdin",
timeoutSeconds: 45,
noOutputTimeoutSeconds: 10,
outputMaxBytes: 4096,
};
await saveCronStore(store.storePath, payload);
expect((await loadCronStore(store.storePath)).jobs[0]?.payload).toEqual({
kind: "command",
argv: ["sh", "-lc", 'printf %s "$1"', " "],
cwd: "/srv/example",
env: { FOO: "bar" },
input: "stdin",
timeoutSeconds: 45,
noOutputTimeoutSeconds: 10,
outputMaxBytes: 4096,
});
});
it("round-trips completion destinations through SQLite delivery columns", async () => {
const { storePath } = await makeStorePath();
const job = makeStore("sqlite-webhook-delivery-job", true).jobs[0];

View File

@@ -14,6 +14,51 @@ function parseExternalContentSource(raw: string | null): "gmail" | "webhook" | u
return parsed === "gmail" || parsed === "webhook" ? parsed : undefined;
}
function parseCommandPayloadMessage(
raw: string | null,
): Omit<Extract<CronPayload, { kind: "command" }>, "kind" | "timeoutSeconds"> | null {
const parsed = raw ? parseJsonValue<unknown>(raw, undefined) : undefined;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
if (
!Array.isArray(record.argv) ||
record.argv.length === 0 ||
record.argv.some((value) => typeof value !== "string" || value.length === 0)
) {
return null;
}
const argv = record.argv.map((value) => String(value));
const env =
record.env && typeof record.env === "object" && !Array.isArray(record.env)
? Object.fromEntries(
Object.entries(record.env as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
: undefined;
const rawNoOutputTimeoutSeconds =
typeof record.noOutputTimeoutSeconds === "number" ||
typeof record.noOutputTimeoutSeconds === "bigint"
? record.noOutputTimeoutSeconds
: null;
const rawOutputMaxBytes =
typeof record.outputMaxBytes === "number" || typeof record.outputMaxBytes === "bigint"
? record.outputMaxBytes
: null;
const noOutputTimeoutSeconds = normalizeNumber(rawNoOutputTimeoutSeconds);
const outputMaxBytes = normalizeNumber(rawOutputMaxBytes);
return {
argv,
...(typeof record.cwd === "string" && record.cwd.trim() ? { cwd: record.cwd } : {}),
...(env && Object.keys(env).length > 0 ? { env } : {}),
...(typeof record.input === "string" ? { input: record.input } : {}),
...(noOutputTimeoutSeconds != null ? { noOutputTimeoutSeconds } : {}),
...(outputMaxBytes != null && outputMaxBytes > 0 ? { outputMaxBytes } : {}),
};
}
/** Maps cron payload variants into normalized SQLite columns. */
export function bindPayloadColumns(
payload: CronPayload,
@@ -44,6 +89,21 @@ export function bindPayloadColumns(
payload_tools_allow_json: null,
};
}
if (payload.kind === "command") {
const { timeoutSeconds: _timeoutSeconds, ...payloadMessage } = payload;
return {
payload_kind: "command",
payload_message: serializeJson(payloadMessage),
payload_model: null,
payload_fallbacks_json: null,
payload_thinking: null,
payload_timeout_seconds: payload.timeoutSeconds ?? null,
payload_allow_unsafe_external_content: null,
payload_external_content_source_json: null,
payload_light_context: null,
payload_tools_allow_json: null,
};
}
return {
payload_kind: "agentTurn",
payload_message: payload.message,
@@ -96,5 +156,17 @@ export function payloadFromRow(row: CronJobRow): CronPayload | null {
...(toolsAllow ? { toolsAllow } : {}),
};
}
if (row.payload_kind === "command") {
const command = parseCommandPayloadMessage(row.payload_message);
if (!command) {
return null;
}
const timeoutSeconds = normalizeNumber(row.payload_timeout_seconds);
return {
kind: "command",
...command,
...(timeoutSeconds != null ? { timeoutSeconds } : {}),
};
}
return null;
}

View File

@@ -220,11 +220,17 @@ export type CronFailureAlert = {
accountId?: string;
};
/** Payload variants cron can execute in main-session or isolated-agent modes. */
export type CronPayload = { kind: "systemEvent"; text: string } | CronAgentTurnPayload;
/** Payload variants cron can execute in main-session or detached modes. */
export type CronPayload =
| { kind: "systemEvent"; text: string }
| CronAgentTurnPayload
| CronCommandPayload;
/** Partial payload update shape used by cron patch/edit flows. */
export type CronPayloadPatch = { kind: "systemEvent"; text?: string } | CronAgentTurnPayloadPatch;
export type CronPayloadPatch =
| { kind: "systemEvent"; text?: string }
| CronAgentTurnPayloadPatch
| CronCommandPayloadPatch;
type CronAgentTurnPayloadFields = {
message: string;
@@ -252,6 +258,25 @@ type CronAgentTurnPayloadPatch = {
} & Partial<Omit<CronAgentTurnPayloadFields, "toolsAllow">> & {
toolsAllow?: string[] | null;
};
type CronCommandPayloadFields = {
/** Explicit argv vector to execute. Use a shell wrapper argv for shell syntax. */
argv: string[];
cwd?: string;
env?: Record<string, string>;
input?: string;
timeoutSeconds?: number;
noOutputTimeoutSeconds?: number;
outputMaxBytes?: number;
};
type CronCommandPayload = {
kind: "command";
} & CronCommandPayloadFields;
type CronCommandPayloadPatch = {
kind: "command";
} & Partial<CronCommandPayloadFields>;
/** Mutable runtime state persisted beside the immutable cron job spec. */
export type CronJobState = {
nextRunAtMs?: number;

View File

@@ -402,6 +402,79 @@ describe("buildGatewayCronService", () => {
}
});
it("suppresses command cron NO_REPLY output before announce delivery", async () => {
const cfg = createCronConfig("server-cron-command-no-reply");
loadConfigMock.mockReturnValue(cfg);
const state = buildGatewayCronService({
cfg,
deps: {} as CliDeps,
broadcast: () => {},
});
try {
const job = await state.cron.add({
name: "silent-command",
enabled: true,
deleteAfterRun: false,
schedule: { kind: "at", at: new Date(1).toISOString() },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: {
kind: "command",
argv: [process.execPath, "-e", "process.stdout.write('NO_REPLY\\n')"],
},
delivery: {
mode: "announce",
channel: "telegram",
to: "123",
},
});
await state.cron.run(job.id, "force");
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
expect(state.cron.getJob(job.id)?.state.lastDeliveryError).toBeUndefined();
} finally {
state.cron.stop();
}
});
it("suppresses command cron NO_REPLY output before webhook delivery", async () => {
const cfg = createCronConfig("server-cron-command-webhook-no-reply");
loadConfigMock.mockReturnValue(cfg);
const state = buildGatewayCronService({
cfg,
deps: {} as CliDeps,
broadcast: () => {},
});
try {
const job = await state.cron.add({
name: "silent-command-webhook",
enabled: true,
deleteAfterRun: false,
schedule: { kind: "at", at: new Date(1).toISOString() },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: {
kind: "command",
argv: [process.execPath, "-e", "process.stdout.write('NO_REPLY\\n')"],
},
delivery: {
mode: "webhook",
to: "https://example.invalid/cron-finished",
},
});
await state.cron.run(job.id, "force");
expect(state.cron.getJob(job.id)?.state.lastRunStatus).toBe("ok");
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
} finally {
state.cron.stop();
}
});
it("routes global-scope main cron jobs through the global queue for queued wakes", async () => {
const cfg = {
...createCronConfig("server-cron-global-queued"),

View File

@@ -1,6 +1,7 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { retireSessionMcpRuntime } from "../agents/agent-bundle-mcp-tools.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { abortAndDrainEmbeddedAgentRun } from "../agents/embedded-agent.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js";
import type { CliDeps } from "../cli/deps.types.js";
import { getRuntimeConfig } from "../config/io.js";
@@ -12,11 +13,16 @@ import {
import { resolveStorePath } from "../config/sessions/paths.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { runCronCommandJob } from "../cron/command-runner.js";
import { resolveCronDeliveryPlan, sendCronAnnouncePayloadStrict } from "../cron/delivery.js";
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import { appendCronRunLog, resolveCronRunLogPruneOptions } from "../cron/run-log.js";
import type { CronServiceContract } from "../cron/service-contract.js";
import { CronService } from "../cron/service.js";
import { resolveCronSessionTargetSessionKey } from "../cron/session-target.js";
import {
resolveCronDeliverySessionKey,
resolveCronSessionTargetSessionKey,
} from "../cron/session-target.js";
import { resolveCronJobsStorePath } from "../cron/store.js";
import type { CronJob } from "../cron/types.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -379,6 +385,105 @@ export function buildGatewayCronService(params: {
});
}
},
runCommandJob: async ({ job, abortSignal }) => {
const result = await runCronCommandJob({
job,
abortSignal,
nowMs: Date.now,
});
const plan = resolveCronDeliveryPlan(job);
const deliveryTrace = {
intended: pickDefined(
{
channel: plan.channel,
to: plan.to,
accountId: plan.accountId,
threadId: plan.threadId,
source: "explicit" as const,
},
["channel", "to", "accountId", "threadId", "source"],
),
};
const summaryIsSilent =
typeof result.summary === "string" && isSilentReplyText(result.summary, SILENT_REPLY_TOKEN);
if (summaryIsSilent) {
const { summary: _summary, ...silentResult } = result;
return {
...silentResult,
deliveryAttempted: false,
delivered: false,
delivery: deliveryTrace,
};
}
const shouldAnnounce =
plan.mode === "announce" && typeof result.summary === "string" && result.summary.trim();
if (!shouldAnnounce) {
return {
...result,
deliveryAttempted: false,
delivered: false,
delivery: deliveryTrace,
};
}
const message = result.summary;
if (typeof message !== "string") {
return {
...result,
deliveryAttempted: false,
delivered: false,
delivery: deliveryTrace,
};
}
const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
try {
await sendCronAnnouncePayloadStrict({
deps: params.deps,
cfg: runtimeConfig,
agentId,
jobId: job.id,
target: {
channel: plan.channel,
to: plan.to,
accountId: plan.accountId,
sessionKey: resolveCronDeliverySessionKey(job),
},
message,
abortSignal: abortSignal ?? new AbortController().signal,
});
return {
...result,
deliveryAttempted: true,
delivered: true,
delivery: {
...deliveryTrace,
delivered: true,
},
};
} catch (err) {
const error = formatErrorMessage(err);
cronLogger.warn({ jobId: job.id, err: error }, "cron: command delivery failed");
return {
...result,
status: job.delivery?.bestEffort ? result.status : "error",
error: job.delivery?.bestEffort ? result.error : error,
deliveryAttempted: true,
delivered: false,
delivery: {
...deliveryTrace,
delivered: false,
resolved: {
channel: plan.channel,
to: plan.to,
accountId: plan.accountId,
threadId: plan.threadId,
source: "explicit" as const,
ok: false,
error,
},
},
};
}
},
cleanupTimedOutAgentRun: async ({ job, execution }) => {
if (!execution?.sessionId) {
return;

View File

@@ -35,6 +35,7 @@ export const DEFAULT_CRON_FORM: CronFormState = {
sessionTarget: "isolated",
wakeMode: "now",
payloadKind: "agentTurn",
payloadLocked: false,
payloadText: "",
payloadModel: "",
payloadThinking: "",

View File

@@ -599,6 +599,47 @@ describe("cron controller", () => {
expect(state.cronForm.deliveryAccountId).toBe("bot-2");
});
it("preserves command payloads when editing Control UI metadata", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "cron.update") {
return { id: "job-command" };
}
if (method === "cron.list") {
return { jobs: [{ id: "job-command" }] };
}
if (method === "cron.status") {
return { enabled: true, jobs: 1, nextWakeAtMs: null };
}
return {};
});
const job = {
id: "job-command",
name: "Command",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: { kind: "every" as const, everyMs: 600_000 },
sessionTarget: "isolated" as const,
wakeMode: "next-heartbeat" as const,
payload: { kind: "command" as const, argv: ["sh", "-lc", "echo ok"] },
delivery: { mode: "announce" as const, channel: "telegram", to: "123" },
state: {},
};
const state = createState({
client: { request } as unknown as CronState["client"],
cronJobs: [job],
});
startCronEdit(state, job);
state.cronForm.name = "Command renamed";
await addCronJob(state);
const updateCall = findRequestCall(request.mock.calls, "cron.update");
const patch = requestPatch(updateCall);
expect(patch.name).toBe("Command renamed");
expect(patch).not.toHaveProperty("payload");
});
it('keeps implicit announce delivery implicit when editing a job that shows "last" in the form', async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "cron.update") {

View File

@@ -96,8 +96,10 @@ export type CronModelSuggestionsState = {
cronModelSuggestions: string[];
};
function supportsAnnounceDelivery(form: Pick<CronFormState, "sessionTarget" | "payloadKind">) {
return form.sessionTarget !== "main" && form.payloadKind === "agentTurn";
function supportsAnnounceDelivery(
form: Pick<CronFormState, "sessionTarget" | "payloadKind" | "payloadLocked">,
) {
return form.sessionTarget !== "main" && (form.payloadKind === "agentTurn" || form.payloadLocked);
}
export function normalizeCronFormState(form: CronFormState): CronFormState {
@@ -142,13 +144,13 @@ export function validateCronForm(form: CronFormState): CronFieldErrors {
}
}
}
if (!form.payloadText.trim()) {
if (!form.payloadLocked && !form.payloadText.trim()) {
errors.payloadText =
form.payloadKind === "systemEvent"
? "cron.errors.systemTextRequired"
: "cron.errors.agentMessageRequired";
}
if (form.payloadKind === "agentTurn") {
if (!form.payloadLocked && form.payloadKind === "agentTurn") {
const timeoutRaw = form.timeoutSeconds.trim();
if (timeoutRaw) {
const timeout = toNumber(timeoutRaw, 0);
@@ -484,6 +486,7 @@ function parseStaggerSchedule(
function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
const failureAlert = job.failureAlert;
const payload = getCronJobPayload(job);
const payloadLocked = payload?.kind === "command";
const next: CronFormState = {
...prev,
name: job.name,
@@ -504,8 +507,19 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
staggerUnit: "seconds",
sessionTarget: job.sessionTarget,
wakeMode: job.wakeMode,
payloadKind: payload?.kind ?? DEFAULT_CRON_FORM.payloadKind,
payloadText: payload?.kind === "systemEvent" ? payload.text : (payload?.message ?? ""),
payloadKind:
payload?.kind === "systemEvent" || payload?.kind === "agentTurn"
? payload.kind
: DEFAULT_CRON_FORM.payloadKind,
payloadLocked,
payloadText:
payload?.kind === "systemEvent"
? payload.text
: payload?.kind === "agentTurn"
? payload.message
: payload?.kind === "command"
? payload.argv.join(" ")
: "",
payloadModel: payload?.kind === "agentTurn" ? (payload.model ?? "") : "",
payloadThinking: payload?.kind === "agentTurn" ? (payload.thinking ?? "") : "",
payloadLightContext: payload?.kind === "agentTurn" ? payload.lightContext === true : false,
@@ -698,12 +712,15 @@ export async function addCronJob(state: CronState): Promise<boolean> {
}
const schedule = buildCronSchedule(form);
const payload = buildCronPayload(form);
const editingJob = state.cronEditingJobId
? state.cronJobs.find((job) => job.id === state.cronEditingJobId)
: undefined;
const editingPayload = editingJob ? getCronJobPayload(editingJob) : null;
if (payload.kind === "agentTurn") {
const preserveLockedPayload = Boolean(
state.cronEditingJobId && form.payloadLocked && editingPayload?.kind === "command",
);
const payload = preserveLockedPayload ? undefined : buildCronPayload(form);
if (payload?.kind === "agentTurn") {
const existingLightContext =
editingPayload?.kind === "agentTurn" ? editingPayload.lightContext : undefined;
if (
@@ -742,7 +759,7 @@ export async function addCronJob(state: CronState): Promise<boolean> {
const agentId = form.clearAgent ? null : form.agentId.trim();
const sessionKeyRaw = form.sessionKey.trim();
const sessionKey = sessionKeyRaw || (editingJob?.sessionKey ? null : undefined);
const job = {
const job: Record<string, unknown> = {
name: form.name.trim(),
description: form.description.trim(),
agentId: agentId === null ? null : agentId || undefined,
@@ -752,10 +769,12 @@ export async function addCronJob(state: CronState): Promise<boolean> {
schedule,
sessionTarget: form.sessionTarget,
wakeMode: form.wakeMode,
payload,
delivery,
failureAlert,
};
if (payload) {
job.payload = payload;
}
if (!job.name) {
throw new Error(t("cron.errors.nameRequiredShort"));
}
@@ -943,6 +962,11 @@ export function startCronClone(state: CronState, job: CronJob) {
);
const cloned = jobToForm(job, state.cronForm);
cloned.name = buildCloneName(job.name, existingNames);
if (cloned.payloadLocked) {
cloned.payloadLocked = false;
cloned.payloadKind = DEFAULT_CRON_FORM.payloadKind;
cloned.payloadText = "";
}
state.cronForm = cloned;
state.cronFieldErrors = validateCronForm(state.cronForm);
}

View File

@@ -14,6 +14,9 @@ export function isCronPayload(value: unknown): value is CronPayload {
if (value.kind === "agentTurn") {
return typeof value.message === "string";
}
if (value.kind === "command") {
return Array.isArray(value.argv) && value.argv.every((arg) => typeof arg === "string");
}
return false;
}

View File

@@ -78,6 +78,9 @@ export function formatCronPayload(job: CronJob) {
if (p.kind === "systemEvent") {
return `System: ${p.text}`;
}
if (p.kind === "command") {
return `Command: ${p.argv.join(" ")}`;
}
const base = `Agent: ${p.message}`;
const delivery = job.delivery;
if (delivery && delivery.mode !== "none") {

View File

@@ -547,6 +547,16 @@ export type CronWakeMode = "next-heartbeat" | "now";
export type CronPayload =
| { kind: "systemEvent"; text: string }
| {
kind: "command";
argv: string[];
cwd?: string;
env?: Record<string, string>;
input?: string;
timeoutSeconds?: number;
noOutputTimeoutSeconds?: number;
outputMaxBytes?: number;
}
| {
kind: "agentTurn";
message: string;

View File

@@ -54,6 +54,7 @@ export type CronFormState = {
sessionTarget: "main" | "isolated" | "current" | `session:${string}`;
wakeMode: "next-heartbeat" | "now";
payloadKind: "systemEvent" | "agentTurn";
payloadLocked: boolean;
payloadText: string;
payloadModel: string;
payloadThinking: string;

View File

@@ -361,7 +361,8 @@ function renderFieldLabel(text: string, required = false) {
export function renderCron(props: CronProps) {
const isEditing = Boolean(props.editingJobId);
const isAgentTurn = props.form.payloadKind === "agentTurn";
const payloadLocked = props.form.payloadLocked;
const isAgentTurn = !payloadLocked && props.form.payloadKind === "agentTurn";
const isCronSchedule = props.form.scheduleKind === "cron";
const channelOptions = buildChannelOptions(props);
const selectedJob =
@@ -384,7 +385,8 @@ export function renderCron(props: CronProps) {
const statusSummary = summarizeSelection(selectedStatusLabels, t("cron.runs.allStatuses"));
const deliverySummary = summarizeSelection(selectedDeliveryLabels, t("cron.runs.allDelivery"));
const supportsAnnounce =
props.form.sessionTarget !== "main" && props.form.payloadKind === "agentTurn";
props.form.sessionTarget !== "main" &&
(props.form.payloadKind === "agentTurn" || payloadLocked);
const selectedDeliveryMode =
props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode;
const formOpen = props.cronFormCollapsed === false || isEditing;
@@ -918,26 +920,35 @@ export function renderCron(props: CronProps) {
</select>
<div class="cron-help">${t("cron.form.wakeModeHelp")}</div>
</label>
<label class="field ${isAgentTurn ? "" : "cron-span-2"}">
${renderFieldLabel(t("cron.form.payloadKind"))}
<select
id="cron-payload-kind"
.value=${props.form.payloadKind}
@change=${(e: Event) =>
props.onFormChange({
payloadKind: (e.target as HTMLSelectElement)
.value as CronFormState["payloadKind"],
})}
>
<option value="systemEvent">${t("cron.form.systemEvent")}</option>
<option value="agentTurn">${t("cron.form.agentTurn")}</option>
</select>
<div class="cron-help">
${props.form.payloadKind === "systemEvent"
? t("cron.form.systemEventHelp")
: t("cron.form.agentTurnHelp")}
</div>
</label>
${payloadLocked
? html`
<label class="field cron-span-2">
${renderFieldLabel(t("cron.form.payloadKind"))}
<input id="cron-payload-kind" .value=${"Command"} readonly />
</label>
`
: html`
<label class="field ${isAgentTurn ? "" : "cron-span-2"}">
${renderFieldLabel(t("cron.form.payloadKind"))}
<select
id="cron-payload-kind"
.value=${props.form.payloadKind}
@change=${(e: Event) =>
props.onFormChange({
payloadKind: (e.target as HTMLSelectElement)
.value as CronFormState["payloadKind"],
})}
>
<option value="systemEvent">${t("cron.form.systemEvent")}</option>
<option value="agentTurn">${t("cron.form.agentTurn")}</option>
</select>
<div class="cron-help">
${props.form.payloadKind === "systemEvent"
? t("cron.form.systemEventHelp")
: t("cron.form.agentTurnHelp")}
</div>
</label>
`}
${isAgentTurn
? html`
<label class="field">
@@ -968,14 +979,17 @@ export function renderCron(props: CronProps) {
</div>
<label class="field cron-span-2">
${renderFieldLabel(
props.form.payloadKind === "systemEvent"
? t("cron.form.mainTimelineMessage")
: t("cron.form.assistantTaskPrompt"),
payloadLocked
? "Command"
: props.form.payloadKind === "systemEvent"
? t("cron.form.mainTimelineMessage")
: t("cron.form.assistantTaskPrompt"),
true,
)}
<textarea
id="cron-payload-text"
.value=${props.form.payloadText}
?readonly=${payloadLocked}
aria-invalid=${props.fieldErrors.payloadText ? "true" : "false"}
aria-describedby=${ifDefined(
props.fieldErrors.payloadText ? errorIdForField("payloadText") : undefined,
@@ -1714,6 +1728,29 @@ function renderJobPayload(job: CronJob) {
? ` (${delivery.channel ?? "last"}${delivery.to ? ` -> ${delivery.to}` : ""})`
: "";
if (payload.kind === "command") {
return html`
<div class="cron-job-detail">
<div class="cron-job-detail-section">
<span class="cron-job-detail-label">Command</span>
<code class="muted cron-job-detail-value">${payload.argv.join(" ")}</code>
</div>
${payload.cwd
? html`<div class="cron-job-detail-section">
<span class="cron-job-detail-label">CWD</span>
<span class="muted cron-job-detail-value">${payload.cwd}</span>
</div>`
: nothing}
${delivery
? html`<div class="cron-job-detail-section">
<span class="cron-job-detail-label">${t("cron.jobDetail.delivery")}</span>
<span class="muted cron-job-detail-value">${delivery.mode}${deliveryTarget}</span>
</div>`
: nothing}
</div>
`;
}
return html`
<div class="cron-job-detail">
<div class="cron-job-detail-section">