feat(cron): introduce delivery modes for isolated jobs

- Added support for new delivery modes in cron jobs: `announce`, `deliver`, and `none`.
- Updated documentation to reflect changes in delivery options and usage examples.
- Enhanced the cron job schema to include delivery configuration.
- Refactored related CLI commands and UI components to accommodate the new delivery settings.
- Improved handling of legacy delivery fields for backward compatibility.

This update allows users to choose how output from isolated jobs is delivered, enhancing flexibility in job management.
This commit is contained in:
Tyler Yust
2026-02-03 13:44:29 -08:00
committed by Peter Steinberger
parent 3a03e38378
commit 511c656cbc
24 changed files with 604 additions and 205 deletions

View File

@@ -174,6 +174,7 @@ JOB SCHEMA (for add action):
"name": "string (optional)",
"schedule": { ... }, // Required: when to run
"payload": { ... }, // Required: what to execute
"delivery": { ... }, // Optional: announce/deliver output (isolated only)
"sessionTarget": "main" | "isolated", // Required
"enabled": true | false // Optional, default true
}
@@ -190,7 +191,13 @@ PAYLOAD TYPES (payload.kind):
- "systemEvent": Injects text as system event into session
{ "kind": "systemEvent", "text": "<message>" }
- "agentTurn": Runs agent with message (isolated sessions only)
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional>, "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
DELIVERY (isolated-only, top-level):
{ "mode": "none|announce|deliver", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
LEGACY DELIVERY (payload, only when delivery is omitted):
{ "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent"

View File

@@ -213,20 +213,15 @@ describe("cron cli", () => {
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: {
kind?: string;
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
};
payload?: { kind?: string; message?: string };
delivery?: { mode?: string; channel?: string; to?: string };
};
};
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload?.deliver).toBe(true);
expect(patch?.patch?.payload?.channel).toBe("telegram");
expect(patch?.patch?.payload?.to).toBe("19098680");
expect(patch?.patch?.delivery?.mode).toBe("deliver");
expect(patch?.patch?.delivery?.channel).toBe("telegram");
expect(patch?.patch?.delivery?.to).toBe("19098680");
expect(patch?.patch?.payload?.message).toBeUndefined();
});
@@ -242,11 +237,11 @@ describe("cron cli", () => {
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: { payload?: { kind?: string; deliver?: boolean } };
patch?: { payload?: { kind?: string }; delivery?: { mode?: string } };
};
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.payload?.deliver).toBe(false);
expect(patch?.patch?.delivery?.mode).toBe("none");
});
it("does not include undefined delivery fields when updating message", async () => {
@@ -272,6 +267,7 @@ describe("cron cli", () => {
to?: string;
bestEffortDeliver?: boolean;
};
delivery?: unknown;
};
};
@@ -283,6 +279,7 @@ describe("cron cli", () => {
expect(patch?.patch?.payload).not.toHaveProperty("channel");
expect(patch?.patch?.payload).not.toHaveProperty("to");
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
expect(patch?.patch).not.toHaveProperty("delivery");
});
it("includes delivery fields when explicitly provided with message", async () => {
@@ -313,20 +310,16 @@ describe("cron cli", () => {
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: {
message?: string;
deliver?: boolean;
channel?: string;
to?: string;
};
payload?: { message?: string };
delivery?: { mode?: string; channel?: string; to?: string };
};
};
// Should include everything
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.payload?.deliver).toBe(true);
expect(patch?.patch?.payload?.channel).toBe("telegram");
expect(patch?.patch?.payload?.to).toBe("19098680");
expect(patch?.patch?.delivery?.mode).toBe("deliver");
expect(patch?.patch?.delivery?.channel).toBe("telegram");
expect(patch?.patch?.delivery?.to).toBe("19098680");
});
it("includes best-effort delivery when provided with message", async () => {

View File

@@ -80,11 +80,12 @@ export function registerCronAddCommand(cron: Command) {
.option("--thinking <level>", "Thinking level for agent jobs (off|minimal|low|medium|high)")
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--announce", "Announce summary to a chat (subagent-style)", false)
.option(
"--deliver",
"Deliver agent output (required when using last-route delivery without --to)",
false,
"Deliver full output to a chat (required when using last-route delivery without --to)",
)
.option("--no-deliver", "Disable delivery and skip main-session summary")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
.option(
"--to <dest>",
@@ -158,6 +159,15 @@ export function registerCronAddCommand(cron: Command) {
return { kind: "systemEvent" as const, text: systemEvent };
}
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
const hasAnnounce = Boolean(opts.announce);
const hasDeliver = opts.deliver === true;
const hasNoDeliver = opts.deliver === false;
const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter(
Boolean,
).length;
if (deliveryFlagCount > 1) {
throw new Error("Choose at most one of --announce, --deliver, or --no-deliver");
}
return {
kind: "agentTurn" as const,
message,
@@ -169,10 +179,15 @@ export function registerCronAddCommand(cron: Command) {
: undefined,
timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
deliver: opts.deliver ? true : undefined,
channel: typeof opts.channel === "string" ? opts.channel : "last",
channel:
typeof opts.channel === "string" && opts.channel.trim()
? opts.channel.trim()
: "last",
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
bestEffortDeliver: opts.bestEffortDeliver ? true : undefined,
bestEffortDeliver:
!hasAnnounce && !hasDeliver && !hasNoDeliver && opts.bestEffortDeliver
? true
: undefined,
};
})();
@@ -182,6 +197,12 @@ export function registerCronAddCommand(cron: Command) {
if (sessionTarget === "isolated" && payload.kind !== "agentTurn") {
throw new Error("Isolated jobs require --message (agentTurn).");
}
if (
(opts.announce || typeof opts.deliver === "boolean") &&
(sessionTarget !== "isolated" || payload.kind !== "agentTurn")
) {
throw new Error("--announce/--deliver/--no-deliver require --session isolated.");
}
const isolation =
sessionTarget === "isolated"
@@ -222,6 +243,20 @@ export function registerCronAddCommand(cron: Command) {
sessionTarget,
wakeMode,
payload,
delivery:
payload.kind === "agentTurn" &&
sessionTarget === "isolated" &&
(opts.announce || typeof opts.deliver === "boolean")
? {
mode: opts.announce ? "announce" : opts.deliver === true ? "deliver" : "none",
channel:
typeof opts.channel === "string" && opts.channel.trim()
? opts.channel.trim()
: "last",
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
bestEffort: opts.bestEffortDeliver ? true : undefined,
}
: undefined,
isolation,
};

View File

@@ -46,9 +46,10 @@ export function registerCronEditCommand(cron: Command) {
.option("--thinking <level>", "Thinking level for agent jobs")
.option("--model <model>", "Model override for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--announce", "Announce summary to a chat (subagent-style)")
.option(
"--deliver",
"Deliver agent output (required when using last-route delivery without --to)",
"Deliver full output to a chat (required when using last-route delivery without --to)",
)
.option("--no-deliver", "Disable delivery")
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
@@ -74,6 +75,9 @@ export function registerCronEditCommand(cron: Command) {
if (opts.session === "main" && typeof opts.postPrefix === "string") {
throw new Error("--post-prefix only applies to isolated jobs.");
}
if (opts.announce && typeof opts.deliver === "boolean") {
throw new Error("Choose --announce, --deliver, or --no-deliver (not multiple).");
}
const patch: Record<string, unknown> = {};
if (typeof opts.name === "string") {
@@ -151,15 +155,16 @@ export function registerCronEditCommand(cron: Command) {
? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined;
const hasTimeoutSeconds = Boolean(timeoutSeconds && Number.isFinite(timeoutSeconds));
const hasDeliveryModeFlag = opts.announce || typeof opts.deliver === "boolean";
const hasDeliveryTarget = typeof opts.channel === "string" || typeof opts.to === "string";
const hasBestEffort = typeof opts.bestEffortDeliver === "boolean";
const hasAgentTurnPatch =
typeof opts.message === "string" ||
Boolean(model) ||
Boolean(thinking) ||
hasTimeoutSeconds ||
typeof opts.deliver === "boolean" ||
typeof opts.channel === "string" ||
typeof opts.to === "string" ||
typeof opts.bestEffortDeliver === "boolean";
hasDeliveryModeFlag ||
(!hasDeliveryModeFlag && (hasDeliveryTarget || hasBestEffort));
if (hasSystemEventPatch && hasAgentTurnPatch) {
throw new Error("Choose at most one payload change");
}
@@ -174,15 +179,21 @@ export function registerCronEditCommand(cron: Command) {
assignIf(payload, "model", model, Boolean(model));
assignIf(payload, "thinking", thinking, Boolean(thinking));
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean");
assignIf(payload, "channel", opts.channel, typeof opts.channel === "string");
assignIf(payload, "to", opts.to, typeof opts.to === "string");
assignIf(
payload,
"bestEffortDeliver",
opts.bestEffortDeliver,
typeof opts.bestEffortDeliver === "boolean",
);
if (!hasDeliveryModeFlag) {
const channel =
typeof opts.channel === "string" && opts.channel.trim()
? opts.channel.trim()
: undefined;
const to = typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined;
assignIf(payload, "channel", channel, Boolean(channel));
assignIf(payload, "to", to, Boolean(to));
assignIf(
payload,
"bestEffortDeliver",
opts.bestEffortDeliver,
typeof opts.bestEffortDeliver === "boolean",
);
}
patch.payload = payload;
}
@@ -192,6 +203,24 @@ export function registerCronEditCommand(cron: Command) {
};
}
if (hasDeliveryModeFlag) {
const deliveryMode = opts.announce
? "announce"
: opts.deliver === true
? "deliver"
: "none";
patch.delivery = {
mode: deliveryMode,
channel:
typeof opts.channel === "string" && opts.channel.trim()
? opts.channel.trim()
: undefined,
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
bestEffort:
typeof opts.bestEffortDeliver === "boolean" ? opts.bestEffortDeliver : undefined,
};
}
const res = await callGatewayFromCli("cron.update", opts, {
id,
patch,

80
src/cron/delivery.ts Normal file
View File

@@ -0,0 +1,80 @@
import type { CronDeliveryMode, CronJob, CronMessageChannel } from "./types.js";
export type CronDeliveryPlan = {
mode: CronDeliveryMode;
channel: CronMessageChannel;
to?: string;
bestEffort: boolean;
source: "delivery" | "payload";
requested: boolean;
legacyMode?: "explicit" | "auto" | "off";
};
function normalizeChannel(value: unknown): CronMessageChannel | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return undefined;
}
return trimmed as CronMessageChannel;
}
function normalizeTo(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan {
const payload = job.payload.kind === "agentTurn" ? job.payload : null;
const delivery = job.delivery;
const hasDelivery = delivery && typeof delivery === "object";
const rawMode = hasDelivery ? (delivery as { mode?: unknown }).mode : undefined;
const mode =
rawMode === "none" || rawMode === "announce" || rawMode === "deliver" ? rawMode : undefined;
const payloadChannel = normalizeChannel(payload?.channel);
const payloadTo = normalizeTo(payload?.to);
const payloadBestEffort = payload?.bestEffortDeliver === true;
const deliveryChannel = normalizeChannel(
(delivery as { channel?: unknown } | undefined)?.channel,
);
const deliveryTo = normalizeTo((delivery as { to?: unknown } | undefined)?.to);
const deliveryBestEffortRaw = (delivery as { bestEffort?: unknown } | undefined)?.bestEffort;
const deliveryBestEffort =
typeof deliveryBestEffortRaw === "boolean" ? deliveryBestEffortRaw : undefined;
const channel = (deliveryChannel ?? payloadChannel ?? "last") as CronMessageChannel;
const to = deliveryTo ?? payloadTo;
if (hasDelivery) {
const resolvedMode = mode ?? "none";
return {
mode: resolvedMode,
channel,
to,
bestEffort: deliveryBestEffort ?? false,
source: "delivery",
requested: resolvedMode !== "none",
};
}
const legacyMode =
payload?.deliver === true ? "explicit" : payload?.deliver === false ? "off" : "auto";
const hasExplicitTarget = Boolean(to);
const requested = legacyMode === "explicit" || (legacyMode === "auto" && hasExplicitTarget);
return {
mode: requested ? "deliver" : "none",
channel,
to,
bestEffort: payloadBestEffort,
source: "payload",
requested,
legacyMode,
};
}

View File

@@ -24,6 +24,7 @@ export async function resolveDeliveryTarget(
channel: Exclude<OutboundChannel, "none">;
to?: string;
accountId?: string;
threadId?: string | number;
mode: "explicit" | "implicit";
error?: Error;
}> {
@@ -69,7 +70,13 @@ export async function resolveDeliveryTarget(
const toCandidate = resolved.to;
if (!toCandidate) {
return { channel, to: undefined, accountId: resolved.accountId, mode };
return {
channel,
to: undefined,
accountId: resolved.accountId,
threadId: resolved.threadId,
mode,
};
}
const docked = resolveOutboundTarget({
@@ -83,6 +90,7 @@ export async function resolveDeliveryTarget(
channel,
to: docked.ok ? docked.to : undefined,
accountId: resolved.accountId,
threadId: resolved.threadId,
mode,
error: docked.ok ? undefined : docked.error,
};

View File

@@ -31,6 +31,10 @@ import {
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import {
runSubagentAnnounceFlow,
type SubagentRunOutcome,
} from "../../agents/subagent-announce.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
import { ensureAgentWorkspace } from "../../agents/workspace.js";
@@ -41,7 +45,11 @@ import {
supportsXHighThinking,
} from "../../auto-reply/thinking.js";
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/sessions.js";
import {
resolveAgentMainSessionKey,
resolveSessionTranscriptPath,
updateSessionStore,
} from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
@@ -53,6 +61,7 @@ import {
getHookType,
isExternalHookSession,
} from "../../security/external-content.js";
import { resolveCronDeliveryPlan } from "../delivery.js";
import { resolveDeliveryTarget } from "./delivery-target.js";
import {
isHeartbeatOnlyResponse,
@@ -231,16 +240,15 @@ export async function runCronIsolatedAgentTurn(params: {
});
const agentPayload = params.job.payload.kind === "agentTurn" ? params.job.payload : null;
const deliveryMode =
agentPayload?.deliver === true ? "explicit" : agentPayload?.deliver === false ? "off" : "auto";
const hasExplicitTarget = Boolean(agentPayload?.to && agentPayload.to.trim());
const deliveryRequested =
deliveryMode === "explicit" || (deliveryMode === "auto" && hasExplicitTarget);
const bestEffortDeliver = agentPayload?.bestEffortDeliver === true;
const deliveryPlan = resolveCronDeliveryPlan(params.job);
const deliveryRequested = deliveryPlan.requested;
const bestEffortDeliver = deliveryPlan.bestEffort;
const legacyDeliveryMode =
deliveryPlan.source === "payload" ? deliveryPlan.legacyMode : undefined;
const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, {
channel: agentPayload?.channel ?? "last",
to: agentPayload?.to,
channel: deliveryPlan.channel ?? "last",
to: deliveryPlan.to,
});
const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone);
@@ -424,7 +432,7 @@ export async function runCronIsolatedAgentTurn(params: {
const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars);
const skipMessagingToolDelivery =
deliveryRequested &&
deliveryMode === "auto" &&
legacyDeliveryMode === "auto" &&
runResult.didSendViaMessagingTool === true &&
(runResult.messagingToolSentTargets ?? []).some((target) =>
matchesMessagingToolDeliveryTarget(target, {
@@ -435,38 +443,70 @@ export async function runCronIsolatedAgentTurn(params: {
);
if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) {
if (!resolvedDelivery.to) {
const reason =
resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to).";
if (!bestEffortDeliver) {
if (deliveryPlan.mode === "announce") {
const requesterSessionKey = resolveAgentMainSessionKey({
cfg: cfgWithAgentDefaults,
agentId,
});
const useExplicitOrigin = deliveryPlan.channel !== "last" || Boolean(deliveryPlan.to?.trim());
const requesterOrigin = useExplicitOrigin
? {
channel: resolvedDelivery.channel,
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
threadId: resolvedDelivery.threadId,
}
: undefined;
const outcome: SubagentRunOutcome = { status: "ok" };
const taskLabel = params.job.name?.trim() || "cron job";
await runSubagentAnnounceFlow({
childSessionKey: agentSessionKey,
childRunId: cronSession.sessionEntry.sessionId,
requesterSessionKey,
requesterOrigin,
requesterDisplayKey: requesterSessionKey,
task: taskLabel,
timeoutMs: 30_000,
cleanup: "keep",
roundOneReply: outputText ?? summary,
waitForCompletion: false,
label: `Cron: ${taskLabel}`,
outcome,
});
} else {
if (!resolvedDelivery.to) {
const reason =
resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to).";
if (!bestEffortDeliver) {
return {
status: "error",
summary,
outputText,
error: reason,
};
}
return {
status: "error",
summary,
status: "skipped",
summary: `Delivery skipped (${reason}).`,
outputText,
error: reason,
};
}
return {
status: "skipped",
summary: `Delivery skipped (${reason}).`,
outputText,
};
}
try {
await deliverOutboundPayloads({
cfg: cfgWithAgentDefaults,
channel: resolvedDelivery.channel,
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
payloads,
bestEffort: bestEffortDeliver,
deps: createOutboundSendDeps(params.deps),
});
} catch (err) {
if (!bestEffortDeliver) {
return { status: "error", summary, outputText, error: String(err) };
try {
await deliverOutboundPayloads({
cfg: cfgWithAgentDefaults,
channel: resolvedDelivery.channel,
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
payloads,
bestEffort: bestEffortDeliver,
deps: createOutboundSendDeps(params.deps),
});
} catch (err) {
if (!bestEffortDeliver) {
return { status: "error", summary, outputText, error: String(err) };
}
return { status: "ok", summary, outputText };
}
return { status: "ok", summary, outputText };
}
}

View File

@@ -110,4 +110,28 @@ describe("normalizeCronJobCreate", () => {
expect(schedule.kind).toBe("at");
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
});
it("normalizes delivery mode and channel", () => {
const normalized = normalizeCronJobCreate({
name: "delivery",
enabled: true,
schedule: { kind: "cron", expr: "* * * * *" },
sessionTarget: "isolated",
wakeMode: "now",
payload: {
kind: "agentTurn",
message: "hi",
},
delivery: {
mode: " ANNOUNCE ",
channel: " TeLeGrAm ",
to: " 7200373102 ",
},
}) as unknown as Record<string, unknown>;
const delivery = normalized.delivery as Record<string, unknown>;
expect(delivery.mode).toBe("announce");
expect(delivery.channel).toBe("telegram");
expect(delivery.to).toBe("7200373102");
});
});

View File

@@ -61,6 +61,30 @@ function coercePayload(payload: UnknownRecord) {
return next;
}
function coerceDelivery(delivery: UnknownRecord) {
const next: UnknownRecord = { ...delivery };
if (typeof delivery.mode === "string") {
next.mode = delivery.mode.trim().toLowerCase();
}
if (typeof delivery.channel === "string") {
const trimmed = delivery.channel.trim().toLowerCase();
if (trimmed) {
next.channel = trimmed;
} else {
delete next.channel;
}
}
if (typeof delivery.to === "string") {
const trimmed = delivery.to.trim();
if (trimmed) {
next.to = trimmed;
} else {
delete next.to;
}
}
return next;
}
function unwrapJob(raw: UnknownRecord) {
if (isRecord(raw.data)) {
return raw.data;
@@ -118,6 +142,10 @@ export function normalizeCronJobInput(
next.payload = coercePayload(base.payload);
}
if (isRecord(base.delivery)) {
next.delivery = coerceDelivery(base.delivery);
}
if (options.applyDefaults) {
if (!next.wakeMode) {
next.wakeMode = "next-heartbeat";

View File

@@ -1,5 +1,7 @@
import crypto from "node:crypto";
import type {
CronDelivery,
CronDeliveryPatch,
CronJob,
CronJobCreate,
CronJobPatch,
@@ -26,6 +28,12 @@ export function assertSupportedJobSpec(job: Pick<CronJob, "sessionTarget" | "pay
}
}
function assertDeliverySupport(job: Pick<CronJob, "sessionTarget" | "delivery">) {
if (job.delivery && job.sessionTarget !== "isolated") {
throw new Error('cron delivery config is only supported for sessionTarget="isolated"');
}
}
export function findJobOrThrow(state: CronServiceState, id: string) {
const job = state.store?.jobs.find((j) => j.id === id);
if (!job) {
@@ -102,12 +110,14 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
sessionTarget: input.sessionTarget,
wakeMode: input.wakeMode,
payload: input.payload,
delivery: input.delivery,
isolation: input.isolation,
state: {
...input.state,
},
};
assertSupportedJobSpec(job);
assertDeliverySupport(job);
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
return job;
}
@@ -137,6 +147,9 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
if (patch.payload) {
job.payload = mergeCronPayload(job.payload, patch.payload);
}
if (patch.delivery) {
job.delivery = mergeCronDelivery(job.delivery, patch.delivery);
}
if (patch.isolation) {
job.isolation = patch.isolation;
}
@@ -147,6 +160,7 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
job.agentId = normalizeOptionalAgentId((patch as { agentId?: unknown }).agentId);
}
assertSupportedJobSpec(job);
assertDeliverySupport(job);
}
function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronPayload {
@@ -219,6 +233,35 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
};
}
function mergeCronDelivery(
existing: CronDelivery | undefined,
patch: CronDeliveryPatch,
): CronDelivery {
const next: CronDelivery = {
mode: existing?.mode ?? "none",
channel: existing?.channel,
to: existing?.to,
bestEffort: existing?.bestEffort,
};
if (typeof patch.mode === "string") {
next.mode = patch.mode;
}
if ("channel" in patch) {
const channel = typeof patch.channel === "string" ? patch.channel.trim() : "";
next.channel = channel ? channel : undefined;
}
if ("to" in patch) {
const to = typeof patch.to === "string" ? patch.to.trim() : "";
next.to = to ? to : undefined;
}
if (typeof patch.bestEffort === "boolean") {
next.bestEffort = patch.bestEffort;
}
return next;
}
export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) {
if (opts.forced) {
return true;

View File

@@ -125,7 +125,7 @@ export async function executeJob(
emit(state, { jobId: job.id, action: "removed" });
}
if (job.sessionTarget === "isolated") {
if (job.sessionTarget === "isolated" && !job.delivery) {
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
const mode = job.isolation?.postToMainMode ?? "summary";

View File

@@ -10,6 +10,17 @@ export type CronWakeMode = "next-heartbeat" | "now";
export type CronMessageChannel = ChannelId | "last";
export type CronDeliveryMode = "none" | "announce" | "deliver";
export type CronDelivery = {
mode: CronDeliveryMode;
channel?: CronMessageChannel;
to?: string;
bestEffort?: boolean;
};
export type CronDeliveryPatch = Partial<CronDelivery>;
export type CronPayload =
| { kind: "systemEvent"; text: string }
| {
@@ -75,6 +86,7 @@ export type CronJob = {
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
delivery?: CronDelivery;
isolation?: CronIsolation;
state: CronJobState;
};
@@ -90,5 +102,6 @@ export type CronJobCreate = Omit<CronJob, "id" | "createdAtMs" | "updatedAtMs" |
export type CronJobPatch = Partial<Omit<CronJob, "id" | "createdAtMs" | "state" | "payload">> & {
payload?: CronPayloadPatch;
delivery?: CronDeliveryPatch;
state?: Partial<CronJobState>;
};

View File

@@ -75,6 +75,28 @@ export const CronPayloadPatchSchema = Type.Union([
),
]);
export const CronDeliverySchema = Type.Object(
{
mode: Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("deliver")]),
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
bestEffort: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const CronDeliveryPatchSchema = Type.Object(
{
mode: Type.Optional(
Type.Union([Type.Literal("none"), Type.Literal("announce"), Type.Literal("deliver")]),
),
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
bestEffort: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const CronIsolationSchema = Type.Object(
{
postToMainPrefix: Type.Optional(Type.String()),
@@ -112,6 +134,7 @@ export const CronJobSchema = Type.Object(
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
payload: CronPayloadSchema,
delivery: Type.Optional(CronDeliverySchema),
isolation: Type.Optional(CronIsolationSchema),
state: CronJobStateSchema,
},
@@ -138,6 +161,7 @@ export const CronAddParamsSchema = Type.Object(
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
payload: CronPayloadSchema,
delivery: Type.Optional(CronDeliverySchema),
isolation: Type.Optional(CronIsolationSchema),
},
{ additionalProperties: false },
@@ -154,6 +178,7 @@ export const CronJobPatchSchema = Type.Object(
sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])),
wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])),
payload: Type.Optional(CronPayloadPatchSchema),
delivery: Type.Optional(CronDeliveryPatchSchema),
isolation: Type.Optional(CronIsolationSchema),
state: Type.Optional(Type.Partial(CronJobStateSchema)),
},