feat: add cron agent binding
This commit is contained in:
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,36 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
# 2026.1.12-1
|
## 2026.1.12
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Heartbeat: raise default `ackMaxChars` to 300 so any `HEARTBEAT_OK` replies with short padding stay internal (fewer noisy heartbeat posts on providers).
|
- Cron: add optional `agentId` binding (CLI `--agent` / `--clear-agent`), route cron runs + summaries to the chosen agent, and document/test the fallback to the default agent. (#770)
|
||||||
|
|
||||||
## 2026.1.11-5
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Auto-reply: prevent duplicate /status replies (including /usage alias) and add tests for inline + standalone cases.
|
|
||||||
|
|
||||||
## 2026.1.11-4
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- CLI: read the git commit hash from the package root so npm installs show it.
|
|
||||||
|
|
||||||
## 2026.1.11-3
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- CLI: avoid top-level await warnings in the entrypoint on fresh installs.
|
|
||||||
- CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).
|
|
||||||
|
|
||||||
## 2026.1.11-2
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Installer: ensure the CLI entrypoint is executable after npm installs.
|
|
||||||
- Packaging: include `dist/plugins/` in the npm package to avoid missing module errors.
|
|
||||||
|
|
||||||
## 2026.1.11-1
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Installer: include `patches/` in the npm package so postinstall patching works for npm/bun installs.
|
|
||||||
|
|
||||||
## 2026.1.11
|
## 2026.1.11
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ A cron job is a stored record with:
|
|||||||
- a **schedule** (when it should run),
|
- a **schedule** (when it should run),
|
||||||
- a **payload** (what it should do),
|
- a **payload** (what it should do),
|
||||||
- optional **delivery** (where output should be sent).
|
- optional **delivery** (where output should be sent).
|
||||||
|
- optional **agent binding** (`agentId`): run the job under a specific agent; if
|
||||||
|
missing or unknown, the gateway falls back to the default agent.
|
||||||
|
|
||||||
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
|
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
|
||||||
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
|
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
|
||||||
@@ -190,6 +192,16 @@ clawdbot cron add \
|
|||||||
--deliver \
|
--deliver \
|
||||||
--provider whatsapp \
|
--provider whatsapp \
|
||||||
--to "+15551234567"
|
--to "+15551234567"
|
||||||
|
|
||||||
|
Agent selection (multi-agent setups):
|
||||||
|
```bash
|
||||||
|
# Pin a job to agent "ops" (falls back to default if that agent is missing)
|
||||||
|
clawdbot cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
|
||||||
|
|
||||||
|
# Switch or clear the agent on an existing job
|
||||||
|
clawdbot cron edit <jobId> --agent ops
|
||||||
|
clawdbot cron edit <jobId> --clear-agent
|
||||||
|
```
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual run (debug):
|
Manual run (debug):
|
||||||
|
|||||||
@@ -74,6 +74,39 @@ describe("cron cli", () => {
|
|||||||
expect(params?.payload?.thinking).toBe("low");
|
expect(params?.payload?.thinking).toBe("low");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends agent id on cron add", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
[
|
||||||
|
"cron",
|
||||||
|
"add",
|
||||||
|
"--name",
|
||||||
|
"Agent pinned",
|
||||||
|
"--cron",
|
||||||
|
"* * * * *",
|
||||||
|
"--session",
|
||||||
|
"isolated",
|
||||||
|
"--message",
|
||||||
|
"hi",
|
||||||
|
"--agent",
|
||||||
|
"ops",
|
||||||
|
],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const addCall = callGatewayFromCli.mock.calls.find(
|
||||||
|
(call) => call[0] === "cron.add",
|
||||||
|
);
|
||||||
|
const params = addCall?.[2] as { agentId?: string };
|
||||||
|
expect(params?.agentId).toBe("ops");
|
||||||
|
});
|
||||||
|
|
||||||
it("omits empty model and thinking on cron edit", async () => {
|
it("omits empty model and thinking on cron edit", async () => {
|
||||||
callGatewayFromCli.mockClear();
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
@@ -142,6 +175,36 @@ describe("cron cli", () => {
|
|||||||
expect(patch?.patch?.payload?.thinking).toBe("high");
|
expect(patch?.patch?.payload?.thinking).toBe("high");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets and clears agent id on cron edit", async () => {
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
const { registerCronCli } = await import("./cron-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerCronCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(
|
||||||
|
["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCall = callGatewayFromCli.mock.calls.find(
|
||||||
|
(call) => call[0] === "cron.update",
|
||||||
|
);
|
||||||
|
const patch = updateCall?.[2] as { patch?: { agentId?: unknown } };
|
||||||
|
expect(patch?.patch?.agentId).toBe("Ops");
|
||||||
|
|
||||||
|
callGatewayFromCli.mockClear();
|
||||||
|
await program.parseAsync(["cron", "edit", "job-2", "--clear-agent"], {
|
||||||
|
from: "user",
|
||||||
|
});
|
||||||
|
const clearCall = callGatewayFromCli.mock.calls.find(
|
||||||
|
(call) => call[0] === "cron.update",
|
||||||
|
);
|
||||||
|
const clearPatch = clearCall?.[2] as { patch?: { agentId?: unknown } };
|
||||||
|
expect(clearPatch?.patch?.agentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not include model/thinking when no payload change is requested", async () => {
|
it("does not include model/thinking when no payload change is requested", async () => {
|
||||||
callGatewayFromCli.mockClear();
|
callGatewayFromCli.mockClear();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
|||||||
import type { CronJob, CronSchedule } from "../cron/types.js";
|
import type { CronJob, CronSchedule } from "../cron/types.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { PROVIDER_IDS } from "../providers/registry.js";
|
import { PROVIDER_IDS } from "../providers/registry.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
@@ -72,6 +73,7 @@ const CRON_NEXT_PAD = 10;
|
|||||||
const CRON_LAST_PAD = 10;
|
const CRON_LAST_PAD = 10;
|
||||||
const CRON_STATUS_PAD = 9;
|
const CRON_STATUS_PAD = 9;
|
||||||
const CRON_TARGET_PAD = 9;
|
const CRON_TARGET_PAD = 9;
|
||||||
|
const CRON_AGENT_PAD = 10;
|
||||||
|
|
||||||
const pad = (value: string, width: number) => value.padEnd(width);
|
const pad = (value: string, width: number) => value.padEnd(width);
|
||||||
|
|
||||||
@@ -139,6 +141,7 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
|
|||||||
pad("Last", CRON_LAST_PAD),
|
pad("Last", CRON_LAST_PAD),
|
||||||
pad("Status", CRON_STATUS_PAD),
|
pad("Status", CRON_STATUS_PAD),
|
||||||
pad("Target", CRON_TARGET_PAD),
|
pad("Target", CRON_TARGET_PAD),
|
||||||
|
pad("Agent", CRON_AGENT_PAD),
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
runtime.log(rich ? theme.heading(header) : header);
|
runtime.log(rich ? theme.heading(header) : header);
|
||||||
@@ -162,6 +165,10 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
|
|||||||
const statusRaw = formatStatus(job);
|
const statusRaw = formatStatus(job);
|
||||||
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
|
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
|
||||||
const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD);
|
const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD);
|
||||||
|
const agentLabel = pad(
|
||||||
|
truncate(job.agentId ?? "default", CRON_AGENT_PAD),
|
||||||
|
CRON_AGENT_PAD,
|
||||||
|
);
|
||||||
|
|
||||||
const coloredStatus = (() => {
|
const coloredStatus = (() => {
|
||||||
if (statusRaw === "ok") return colorize(rich, theme.success, statusLabel);
|
if (statusRaw === "ok") return colorize(rich, theme.success, statusLabel);
|
||||||
@@ -178,6 +185,9 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
|
|||||||
job.sessionTarget === "isolated"
|
job.sessionTarget === "isolated"
|
||||||
? colorize(rich, theme.accentBright, targetLabel)
|
? colorize(rich, theme.accentBright, targetLabel)
|
||||||
: colorize(rich, theme.accent, targetLabel);
|
: colorize(rich, theme.accent, targetLabel);
|
||||||
|
const coloredAgent = job.agentId
|
||||||
|
? colorize(rich, theme.info, agentLabel)
|
||||||
|
: colorize(rich, theme.muted, agentLabel);
|
||||||
|
|
||||||
const line = [
|
const line = [
|
||||||
colorize(rich, theme.accent, idLabel),
|
colorize(rich, theme.accent, idLabel),
|
||||||
@@ -187,6 +197,7 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
|
|||||||
colorize(rich, theme.muted, lastLabel),
|
colorize(rich, theme.muted, lastLabel),
|
||||||
coloredStatus,
|
coloredStatus,
|
||||||
coloredTarget,
|
coloredTarget,
|
||||||
|
coloredAgent,
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
runtime.log(line.trimEnd());
|
runtime.log(line.trimEnd());
|
||||||
@@ -283,6 +294,7 @@ export function registerCronCli(program: Command) {
|
|||||||
.requiredOption("--name <name>", "Job name")
|
.requiredOption("--name <name>", "Job name")
|
||||||
.option("--description <text>", "Optional description")
|
.option("--description <text>", "Optional description")
|
||||||
.option("--disabled", "Create job disabled", false)
|
.option("--disabled", "Create job disabled", false)
|
||||||
|
.option("--agent <id>", "Agent id for this job")
|
||||||
.option("--session <target>", "Session target (main|isolated)", "main")
|
.option("--session <target>", "Session target (main|isolated)", "main")
|
||||||
.option(
|
.option(
|
||||||
"--wake <mode>",
|
"--wake <mode>",
|
||||||
@@ -375,6 +387,11 @@ export function registerCronCli(program: Command) {
|
|||||||
throw new Error("--wake must be now or next-heartbeat");
|
throw new Error("--wake must be now or next-heartbeat");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentId =
|
||||||
|
typeof opts.agent === "string" && opts.agent.trim()
|
||||||
|
? normalizeAgentId(opts.agent)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const payload = (() => {
|
const payload = (() => {
|
||||||
const systemEvent =
|
const systemEvent =
|
||||||
typeof opts.systemEvent === "string"
|
typeof opts.systemEvent === "string"
|
||||||
@@ -451,6 +468,7 @@ export function registerCronCli(program: Command) {
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
enabled: !opts.disabled,
|
enabled: !opts.disabled,
|
||||||
|
agentId,
|
||||||
schedule,
|
schedule,
|
||||||
sessionTarget,
|
sessionTarget,
|
||||||
wakeMode,
|
wakeMode,
|
||||||
@@ -561,6 +579,8 @@ export function registerCronCli(program: Command) {
|
|||||||
.option("--enable", "Enable job", false)
|
.option("--enable", "Enable job", false)
|
||||||
.option("--disable", "Disable job", false)
|
.option("--disable", "Disable job", false)
|
||||||
.option("--session <target>", "Session target (main|isolated)")
|
.option("--session <target>", "Session target (main|isolated)")
|
||||||
|
.option("--agent <id>", "Set agent id")
|
||||||
|
.option("--clear-agent", "Unset agent and use default", false)
|
||||||
.option("--wake <mode>", "Wake mode (now|next-heartbeat)")
|
.option("--wake <mode>", "Wake mode (now|next-heartbeat)")
|
||||||
.option("--at <when>", "Set one-shot time (ISO) or duration like 20m")
|
.option("--at <when>", "Set one-shot time (ISO) or duration like 20m")
|
||||||
.option("--every <duration>", "Set interval duration like 10m")
|
.option("--every <duration>", "Set interval duration like 10m")
|
||||||
@@ -613,6 +633,15 @@ export function registerCronCli(program: Command) {
|
|||||||
if (typeof opts.session === "string")
|
if (typeof opts.session === "string")
|
||||||
patch.sessionTarget = opts.session;
|
patch.sessionTarget = opts.session;
|
||||||
if (typeof opts.wake === "string") patch.wakeMode = opts.wake;
|
if (typeof opts.wake === "string") patch.wakeMode = opts.wake;
|
||||||
|
if (opts.agent && opts.clearAgent) {
|
||||||
|
throw new Error("Use --agent or --clear-agent, not both");
|
||||||
|
}
|
||||||
|
if (typeof opts.agent === "string" && opts.agent.trim()) {
|
||||||
|
patch.agentId = normalizeAgentId(opts.agent);
|
||||||
|
}
|
||||||
|
if (opts.clearAgent) {
|
||||||
|
patch.agentId = null;
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleChosen = [opts.at, opts.every, opts.cron].filter(
|
const scheduleChosen = [opts.at, opts.every, opts.cron].filter(
|
||||||
Boolean,
|
Boolean,
|
||||||
|
|||||||
@@ -120,6 +120,75 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses agentId for workspace, session key, and store paths", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const deps: CliDeps = {
|
||||||
|
sendMessageWhatsApp: vi.fn(),
|
||||||
|
sendMessageTelegram: vi.fn(),
|
||||||
|
sendMessageDiscord: vi.fn(),
|
||||||
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendMessageIMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
const opsWorkspace = path.join(home, "ops-workspace");
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
meta: {
|
||||||
|
durationMs: 5,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = makeCfg(
|
||||||
|
home,
|
||||||
|
path.join(
|
||||||
|
home,
|
||||||
|
".clawdbot",
|
||||||
|
"agents",
|
||||||
|
"{agentId}",
|
||||||
|
"sessions",
|
||||||
|
"sessions.json",
|
||||||
|
),
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: { workspace: path.join(home, "default-workspace") },
|
||||||
|
list: [
|
||||||
|
{ id: "main", default: true },
|
||||||
|
{ id: "ops", workspace: opsWorkspace },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await runCronIsolatedAgentTurn({
|
||||||
|
cfg,
|
||||||
|
deps,
|
||||||
|
job: {
|
||||||
|
...makeJob({
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: "do it",
|
||||||
|
deliver: false,
|
||||||
|
provider: "last",
|
||||||
|
}),
|
||||||
|
agentId: "ops",
|
||||||
|
},
|
||||||
|
message: "do it",
|
||||||
|
sessionKey: "cron:job-ops",
|
||||||
|
agentId: "ops",
|
||||||
|
lane: "cron",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe("ok");
|
||||||
|
const call = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0] as {
|
||||||
|
sessionKey?: string;
|
||||||
|
workspaceDir?: string;
|
||||||
|
sessionFile?: string;
|
||||||
|
};
|
||||||
|
expect(call?.sessionKey).toBe("agent:ops:cron:job-ops");
|
||||||
|
expect(call?.workspaceDir).toBe(opsWorkspace);
|
||||||
|
expect(call?.sessionFile).toContain(path.join("agents", "ops"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("uses model override when provided", async () => {
|
it("uses model override when provided", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const storePath = await writeSessionStore(home);
|
const storePath = await writeSessionStore(home);
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import {
|
||||||
|
resolveAgentConfig,
|
||||||
|
resolveAgentWorkspaceDir,
|
||||||
|
resolveDefaultAgentId,
|
||||||
|
} from "../agents/agent-scope.js";
|
||||||
import { runCliAgent } from "../agents/cli-runner.js";
|
import { runCliAgent } from "../agents/cli-runner.js";
|
||||||
import { getCliSessionId, setCliSessionId } from "../agents/cli-session.js";
|
import { getCliSessionId, setCliSessionId } from "../agents/cli-session.js";
|
||||||
import { lookupContextTokens } from "../agents/context.js";
|
import { lookupContextTokens } from "../agents/context.js";
|
||||||
@@ -21,10 +26,7 @@ import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
|||||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
import { hasNonzeroUsage } from "../agents/usage.js";
|
import { hasNonzeroUsage } from "../agents/usage.js";
|
||||||
import {
|
import { ensureAgentWorkspace } from "../agents/workspace.js";
|
||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
|
||||||
ensureAgentWorkspace,
|
|
||||||
} from "../agents/workspace.js";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
@@ -35,13 +37,13 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentMainSessionKey,
|
||||||
resolveMainSessionKey,
|
|
||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import type { AgentDefaultsConfig } from "../config/types.js";
|
||||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||||
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||||
import { resolveMessageProviderSelection } from "../infra/outbound/provider-selection.js";
|
import { resolveMessageProviderSelection } from "../infra/outbound/provider-selection.js";
|
||||||
@@ -52,6 +54,10 @@ import {
|
|||||||
import { normalizeProviderId } from "../providers/plugins/index.js";
|
import { normalizeProviderId } from "../providers/plugins/index.js";
|
||||||
import type { ProviderId } from "../providers/plugins/types.js";
|
import type { ProviderId } from "../providers/plugins/types.js";
|
||||||
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
|
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
|
||||||
|
import {
|
||||||
|
buildAgentMainSessionKey,
|
||||||
|
normalizeAgentId,
|
||||||
|
} from "../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
INTERNAL_MESSAGE_PROVIDER,
|
INTERNAL_MESSAGE_PROVIDER,
|
||||||
normalizeMessageProvider,
|
normalizeMessageProvider,
|
||||||
@@ -113,6 +119,7 @@ function isHeartbeatOnlyResponse(
|
|||||||
|
|
||||||
async function resolveDeliveryTarget(
|
async function resolveDeliveryTarget(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
|
agentId: string,
|
||||||
jobPayload: {
|
jobPayload: {
|
||||||
provider?: "last" | ProviderId;
|
provider?: "last" | ProviderId;
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -134,8 +141,7 @@ async function resolveDeliveryTarget(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const mainSessionKey = resolveMainSessionKey(cfg);
|
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||||
const agentId = resolveAgentIdFromSessionKey(mainSessionKey);
|
|
||||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const main = store[mainSessionKey];
|
const main = store[mainSessionKey];
|
||||||
@@ -187,6 +193,7 @@ function resolveCronSession(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
nowMs: number;
|
nowMs: number;
|
||||||
|
agentId: string;
|
||||||
}) {
|
}) {
|
||||||
const sessionCfg = params.cfg.session;
|
const sessionCfg = params.cfg.session;
|
||||||
const idleMinutes = Math.max(
|
const idleMinutes = Math.max(
|
||||||
@@ -194,7 +201,9 @@ function resolveCronSession(params: {
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
const idleMs = idleMinutes * 60_000;
|
const idleMs = idleMinutes * 60_000;
|
||||||
const storePath = resolveStorePath(sessionCfg?.store);
|
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||||
|
agentId: params.agentId,
|
||||||
|
});
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const entry = store[params.sessionKey];
|
const entry = store[params.sessionKey];
|
||||||
const fresh = entry && params.nowMs - entry.updatedAt <= idleMs;
|
const fresh = entry && params.nowMs - entry.updatedAt <= idleMs;
|
||||||
@@ -221,10 +230,50 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
job: CronJob;
|
job: CronJob;
|
||||||
message: string;
|
message: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
|
agentId?: string;
|
||||||
lane?: string;
|
lane?: string;
|
||||||
}): Promise<RunCronAgentTurnResult> {
|
}): Promise<RunCronAgentTurnResult> {
|
||||||
const agentCfg = params.cfg.agents?.defaults;
|
const defaultAgentId = resolveDefaultAgentId(params.cfg);
|
||||||
const workspaceDirRaw = agentCfg?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
const requestedAgentId =
|
||||||
|
typeof params.agentId === "string" && params.agentId.trim()
|
||||||
|
? params.agentId
|
||||||
|
: typeof params.job.agentId === "string" && params.job.agentId.trim()
|
||||||
|
? params.job.agentId
|
||||||
|
: undefined;
|
||||||
|
const normalizedRequested = requestedAgentId
|
||||||
|
? normalizeAgentId(requestedAgentId)
|
||||||
|
: undefined;
|
||||||
|
const agentConfigOverride = normalizedRequested
|
||||||
|
? resolveAgentConfig(params.cfg, normalizedRequested)
|
||||||
|
: undefined;
|
||||||
|
const { model: overrideModel, ...agentOverrideRest } =
|
||||||
|
agentConfigOverride ?? {};
|
||||||
|
const agentId = agentConfigOverride
|
||||||
|
? (normalizedRequested ?? defaultAgentId)
|
||||||
|
: defaultAgentId;
|
||||||
|
const agentCfg: AgentDefaultsConfig = {
|
||||||
|
...(params.cfg.agents?.defaults ?? {}),
|
||||||
|
...(agentOverrideRest as Partial<AgentDefaultsConfig>),
|
||||||
|
};
|
||||||
|
if (typeof overrideModel === "string") {
|
||||||
|
agentCfg.model = { primary: overrideModel };
|
||||||
|
} else if (overrideModel) {
|
||||||
|
agentCfg.model = overrideModel;
|
||||||
|
}
|
||||||
|
const cfgWithAgentDefaults: ClawdbotConfig = {
|
||||||
|
...params.cfg,
|
||||||
|
agents: { ...(params.cfg.agents ?? {}), defaults: agentCfg },
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseSessionKey = (
|
||||||
|
params.sessionKey?.trim() || `cron:${params.job.id}`
|
||||||
|
).trim();
|
||||||
|
const agentSessionKey = buildAgentMainSessionKey({
|
||||||
|
agentId,
|
||||||
|
mainKey: baseSessionKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceDirRaw = resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||||
const workspace = await ensureAgentWorkspace({
|
const workspace = await ensureAgentWorkspace({
|
||||||
dir: workspaceDirRaw,
|
dir: workspaceDirRaw,
|
||||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||||
@@ -232,7 +281,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
const workspaceDir = workspace.dir;
|
const workspaceDir = workspace.dir;
|
||||||
|
|
||||||
const resolvedDefault = resolveConfiguredModelRef({
|
const resolvedDefault = resolveConfiguredModelRef({
|
||||||
cfg: params.cfg,
|
cfg: cfgWithAgentDefaults,
|
||||||
defaultProvider: DEFAULT_PROVIDER,
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
defaultModel: DEFAULT_MODEL,
|
defaultModel: DEFAULT_MODEL,
|
||||||
});
|
});
|
||||||
@@ -241,12 +290,12 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
let catalog: Awaited<ReturnType<typeof loadModelCatalog>> | undefined;
|
let catalog: Awaited<ReturnType<typeof loadModelCatalog>> | undefined;
|
||||||
const loadCatalog = async () => {
|
const loadCatalog = async () => {
|
||||||
if (!catalog) {
|
if (!catalog) {
|
||||||
catalog = await loadModelCatalog({ config: params.cfg });
|
catalog = await loadModelCatalog({ config: cfgWithAgentDefaults });
|
||||||
}
|
}
|
||||||
return catalog;
|
return catalog;
|
||||||
};
|
};
|
||||||
// Resolve model - prefer hooks.gmail.model for Gmail hooks.
|
// Resolve model - prefer hooks.gmail.model for Gmail hooks.
|
||||||
const isGmailHook = params.sessionKey.startsWith("hook:gmail:");
|
const isGmailHook = baseSessionKey.startsWith("hook:gmail:");
|
||||||
const hooksGmailModelRef = isGmailHook
|
const hooksGmailModelRef = isGmailHook
|
||||||
? resolveHooksGmailModel({
|
? resolveHooksGmailModel({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
@@ -275,7 +324,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
return { status: "error", error: "invalid model: expected string" };
|
return { status: "error", error: "invalid model: expected string" };
|
||||||
}
|
}
|
||||||
const resolvedOverride = resolveAllowedModelRef({
|
const resolvedOverride = resolveAllowedModelRef({
|
||||||
cfg: params.cfg,
|
cfg: cfgWithAgentDefaults,
|
||||||
catalog: await loadCatalog(),
|
catalog: await loadCatalog(),
|
||||||
raw: modelOverrideRaw,
|
raw: modelOverrideRaw,
|
||||||
defaultProvider: resolvedDefault.provider,
|
defaultProvider: resolvedDefault.provider,
|
||||||
@@ -290,7 +339,8 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cronSession = resolveCronSession({
|
const cronSession = resolveCronSession({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: agentSessionKey,
|
||||||
|
agentId,
|
||||||
nowMs: now,
|
nowMs: now,
|
||||||
});
|
});
|
||||||
const isFirstTurnInSession =
|
const isFirstTurnInSession =
|
||||||
@@ -309,7 +359,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
let thinkLevel = jobThink ?? hooksGmailThinking ?? thinkOverride;
|
let thinkLevel = jobThink ?? hooksGmailThinking ?? thinkOverride;
|
||||||
if (!thinkLevel) {
|
if (!thinkLevel) {
|
||||||
thinkLevel = resolveThinkingDefault({
|
thinkLevel = resolveThinkingDefault({
|
||||||
cfg: params.cfg,
|
cfg: cfgWithAgentDefaults,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
catalog: await loadCatalog(),
|
catalog: await loadCatalog(),
|
||||||
@@ -317,7 +367,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeoutMs = resolveAgentTimeoutMs({
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
cfg: params.cfg,
|
cfg: cfgWithAgentDefaults,
|
||||||
overrideSeconds:
|
overrideSeconds:
|
||||||
params.job.payload.kind === "agentTurn"
|
params.job.payload.kind === "agentTurn"
|
||||||
? params.job.payload.timeoutSeconds
|
? params.job.payload.timeoutSeconds
|
||||||
@@ -331,16 +381,20 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
params.job.payload.kind === "agentTurn" &&
|
params.job.payload.kind === "agentTurn" &&
|
||||||
params.job.payload.bestEffortDeliver === true;
|
params.job.payload.bestEffortDeliver === true;
|
||||||
|
|
||||||
const resolvedDelivery = await resolveDeliveryTarget(params.cfg, {
|
const resolvedDelivery = await resolveDeliveryTarget(
|
||||||
provider:
|
cfgWithAgentDefaults,
|
||||||
params.job.payload.kind === "agentTurn"
|
agentId,
|
||||||
? params.job.payload.provider
|
{
|
||||||
: "last",
|
provider:
|
||||||
to:
|
params.job.payload.kind === "agentTurn"
|
||||||
params.job.payload.kind === "agentTurn"
|
? params.job.payload.provider
|
||||||
? params.job.payload.to
|
: "last",
|
||||||
: undefined,
|
to:
|
||||||
});
|
params.job.payload.kind === "agentTurn"
|
||||||
|
? params.job.payload.to
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const base =
|
const base =
|
||||||
`[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
|
`[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
|
||||||
@@ -350,7 +404,9 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
const needsSkillsSnapshot =
|
const needsSkillsSnapshot =
|
||||||
cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
|
cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
|
||||||
const skillsSnapshot = needsSkillsSnapshot
|
const skillsSnapshot = needsSkillsSnapshot
|
||||||
? buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg })
|
? buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||||
|
config: cfgWithAgentDefaults,
|
||||||
|
})
|
||||||
: cronSession.sessionEntry.skillsSnapshot;
|
: cronSession.sessionEntry.skillsSnapshot;
|
||||||
if (needsSkillsSnapshot && skillsSnapshot) {
|
if (needsSkillsSnapshot && skillsSnapshot) {
|
||||||
cronSession.sessionEntry = {
|
cronSession.sessionEntry = {
|
||||||
@@ -358,17 +414,17 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
skillsSnapshot,
|
skillsSnapshot,
|
||||||
};
|
};
|
||||||
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
|
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
|
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
|
||||||
if (isFirstTurnInSession) {
|
if (isFirstTurnInSession) {
|
||||||
cronSession.sessionEntry.systemSent = true;
|
cronSession.sessionEntry.systemSent = true;
|
||||||
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
|
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||||
} else {
|
} else {
|
||||||
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
|
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,31 +434,32 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
try {
|
try {
|
||||||
const sessionFile = resolveSessionTranscriptPath(
|
const sessionFile = resolveSessionTranscriptPath(
|
||||||
cronSession.sessionEntry.sessionId,
|
cronSession.sessionEntry.sessionId,
|
||||||
|
agentId,
|
||||||
);
|
);
|
||||||
const resolvedVerboseLevel =
|
const resolvedVerboseLevel =
|
||||||
(cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ??
|
(cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ??
|
||||||
(agentCfg?.verboseDefault as "on" | "off" | undefined);
|
(agentCfg?.verboseDefault as "on" | "off" | undefined);
|
||||||
registerAgentRunContext(cronSession.sessionEntry.sessionId, {
|
registerAgentRunContext(cronSession.sessionEntry.sessionId, {
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: agentSessionKey,
|
||||||
verboseLevel: resolvedVerboseLevel,
|
verboseLevel: resolvedVerboseLevel,
|
||||||
});
|
});
|
||||||
const messageProvider = resolvedDelivery.provider;
|
const messageProvider = resolvedDelivery.provider;
|
||||||
const fallbackResult = await runWithModelFallback({
|
const fallbackResult = await runWithModelFallback({
|
||||||
cfg: params.cfg,
|
cfg: cfgWithAgentDefaults,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
run: (providerOverride, modelOverride) => {
|
run: (providerOverride, modelOverride) => {
|
||||||
if (isCliProvider(providerOverride, params.cfg)) {
|
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
|
||||||
const cliSessionId = getCliSessionId(
|
const cliSessionId = getCliSessionId(
|
||||||
cronSession.sessionEntry,
|
cronSession.sessionEntry,
|
||||||
providerOverride,
|
providerOverride,
|
||||||
);
|
);
|
||||||
return runCliAgent({
|
return runCliAgent({
|
||||||
sessionId: cronSession.sessionEntry.sessionId,
|
sessionId: cronSession.sessionEntry.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: agentSessionKey,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: params.cfg,
|
config: cfgWithAgentDefaults,
|
||||||
prompt: commandBody,
|
prompt: commandBody,
|
||||||
provider: providerOverride,
|
provider: providerOverride,
|
||||||
model: modelOverride,
|
model: modelOverride,
|
||||||
@@ -414,11 +471,11 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
}
|
}
|
||||||
return runEmbeddedPiAgent({
|
return runEmbeddedPiAgent({
|
||||||
sessionId: cronSession.sessionEntry.sessionId,
|
sessionId: cronSession.sessionEntry.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: agentSessionKey,
|
||||||
messageProvider,
|
messageProvider,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: params.cfg,
|
config: cfgWithAgentDefaults,
|
||||||
skillsSnapshot,
|
skillsSnapshot,
|
||||||
prompt: commandBody,
|
prompt: commandBody,
|
||||||
lane: params.lane ?? "cron",
|
lane: params.lane ?? "cron",
|
||||||
@@ -454,7 +511,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
cronSession.sessionEntry.modelProvider = providerUsed;
|
cronSession.sessionEntry.modelProvider = providerUsed;
|
||||||
cronSession.sessionEntry.model = modelUsed;
|
cronSession.sessionEntry.model = modelUsed;
|
||||||
cronSession.sessionEntry.contextTokens = contextTokens;
|
cronSession.sessionEntry.contextTokens = contextTokens;
|
||||||
if (isCliProvider(providerUsed, params.cfg)) {
|
if (isCliProvider(providerUsed, cfgWithAgentDefaults)) {
|
||||||
const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim();
|
const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim();
|
||||||
if (cliSessionId) {
|
if (cliSessionId) {
|
||||||
setCliSessionId(cronSession.sessionEntry, providerUsed, cliSessionId);
|
setCliSessionId(cronSession.sessionEntry, providerUsed, cliSessionId);
|
||||||
@@ -470,7 +527,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
cronSession.sessionEntry.totalTokens =
|
cronSession.sessionEntry.totalTokens =
|
||||||
promptTokens > 0 ? promptTokens : (usage.total ?? input);
|
promptTokens > 0 ? promptTokens : (usage.total ?? input);
|
||||||
}
|
}
|
||||||
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
|
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||||
}
|
}
|
||||||
const firstText = payloads[0]?.text ?? "";
|
const firstText = payloads[0]?.text ?? "";
|
||||||
@@ -481,8 +538,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
// This allows cron jobs to silently ack when nothing to report but still deliver
|
// This allows cron jobs to silently ack when nothing to report but still deliver
|
||||||
// actual content when there is something to say.
|
// actual content when there is something to say.
|
||||||
const ackMaxChars =
|
const ackMaxChars =
|
||||||
params.cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
|
agentCfg?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
|
||||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
|
|
||||||
const skipHeartbeatDelivery =
|
const skipHeartbeatDelivery =
|
||||||
delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars));
|
delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars));
|
||||||
|
|
||||||
@@ -505,7 +561,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deliverOutboundPayloads({
|
await deliverOutboundPayloads({
|
||||||
cfg: params.cfg,
|
cfg: cfgWithAgentDefaults,
|
||||||
provider: resolvedDelivery.provider as Exclude<
|
provider: resolvedDelivery.provider as Exclude<
|
||||||
OutboundProvider,
|
OutboundProvider,
|
||||||
"none"
|
"none"
|
||||||
|
|||||||
@@ -24,6 +24,38 @@ describe("normalizeCronJobCreate", () => {
|
|||||||
expect("channel" in payload).toBe(false);
|
expect("channel" in payload).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes agentId and drops null", () => {
|
||||||
|
const normalized = normalizeCronJobCreate({
|
||||||
|
name: "agent-set",
|
||||||
|
enabled: true,
|
||||||
|
schedule: { kind: "cron", expr: "* * * * *" },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "now",
|
||||||
|
agentId: " Ops ",
|
||||||
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
}) as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(normalized.agentId).toBe("Ops");
|
||||||
|
|
||||||
|
const cleared = normalizeCronJobCreate({
|
||||||
|
name: "agent-clear",
|
||||||
|
enabled: true,
|
||||||
|
schedule: { kind: "cron", expr: "* * * * *" },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "now",
|
||||||
|
agentId: null,
|
||||||
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
}) as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(cleared.agentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("canonicalizes payload.provider casing", () => {
|
it("canonicalizes payload.provider casing", () => {
|
||||||
const normalized = normalizeCronJobCreate({
|
const normalized = normalizeCronJobCreate({
|
||||||
name: "legacy provider",
|
name: "legacy provider",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { migrateLegacyCronPayload } from "./payload-migration.js";
|
import { migrateLegacyCronPayload } from "./payload-migration.js";
|
||||||
import type { CronJobCreate, CronJobPatch } from "./types.js";
|
import type { CronJobCreate, CronJobPatch } from "./types.js";
|
||||||
|
|
||||||
@@ -53,6 +54,17 @@ export function normalizeCronJobInput(
|
|||||||
const base = unwrapJob(raw);
|
const base = unwrapJob(raw);
|
||||||
const next: UnknownRecord = { ...base };
|
const next: UnknownRecord = { ...base };
|
||||||
|
|
||||||
|
if ("agentId" in base) {
|
||||||
|
const agentId = (base as UnknownRecord).agentId;
|
||||||
|
if (agentId === null) {
|
||||||
|
next.agentId = null;
|
||||||
|
} else if (typeof agentId === "string") {
|
||||||
|
const trimmed = agentId.trim();
|
||||||
|
if (trimmed) next.agentId = normalizeAgentId(trimmed);
|
||||||
|
else delete next.agentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isRecord(base.schedule)) {
|
if (isRecord(base.schedule)) {
|
||||||
next.schedule = coerceSchedule(base.schedule);
|
next.schedule = coerceSchedule(base.schedule);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ describe("CronService", () => {
|
|||||||
const jobs = await cron.list({ includeDisabled: true });
|
const jobs = await cron.list({ includeDisabled: true });
|
||||||
const updated = jobs.find((j) => j.id === job.id);
|
const updated = jobs.find((j) => j.id === job.id);
|
||||||
expect(updated?.enabled).toBe(false);
|
expect(updated?.enabled).toBe(false);
|
||||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("hello");
|
expect(enqueueSystemEvent).toHaveBeenCalledWith("hello", {
|
||||||
|
agentId: undefined,
|
||||||
|
});
|
||||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||||
|
|
||||||
await cron.list({ includeDisabled: true });
|
await cron.list({ includeDisabled: true });
|
||||||
@@ -128,7 +130,9 @@ describe("CronService", () => {
|
|||||||
|
|
||||||
expect(runHeartbeatOnce).toHaveBeenCalledTimes(1);
|
expect(runHeartbeatOnce).toHaveBeenCalledTimes(1);
|
||||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("hello");
|
expect(enqueueSystemEvent).toHaveBeenCalledWith("hello", {
|
||||||
|
agentId: undefined,
|
||||||
|
});
|
||||||
expect(job.state.runningAtMs).toBeTypeOf("number");
|
expect(job.state.runningAtMs).toBeTypeOf("number");
|
||||||
|
|
||||||
resolveHeartbeat?.({ status: "ran", durationMs: 123 });
|
resolveHeartbeat?.({ status: "ran", durationMs: 123 });
|
||||||
@@ -175,7 +179,9 @@ describe("CronService", () => {
|
|||||||
|
|
||||||
await cron.list({ includeDisabled: true });
|
await cron.list({ includeDisabled: true });
|
||||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||||
expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron: done");
|
expect(enqueueSystemEvent).toHaveBeenCalledWith("Cron: done", {
|
||||||
|
agentId: undefined,
|
||||||
|
});
|
||||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||||
cron.stop();
|
cron.stop();
|
||||||
await store.cleanup();
|
await store.cleanup();
|
||||||
@@ -318,6 +324,7 @@ describe("CronService", () => {
|
|||||||
|
|
||||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
"Cron (error): last output",
|
"Cron (error): last output",
|
||||||
|
{ agentId: undefined },
|
||||||
);
|
);
|
||||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||||
cron.stop();
|
cron.stop();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
|
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { truncateUtf16Safe } from "../utils.js";
|
import { truncateUtf16Safe } from "../utils.js";
|
||||||
import { migrateLegacyCronPayload } from "./payload-migration.js";
|
import { migrateLegacyCronPayload } from "./payload-migration.js";
|
||||||
import { computeNextRunAtMs } from "./schedule.js";
|
import { computeNextRunAtMs } from "./schedule.js";
|
||||||
@@ -36,7 +37,7 @@ export type CronServiceDeps = {
|
|||||||
log: Logger;
|
log: Logger;
|
||||||
storePath: string;
|
storePath: string;
|
||||||
cronEnabled: boolean;
|
cronEnabled: boolean;
|
||||||
enqueueSystemEvent: (text: string) => void;
|
enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void;
|
||||||
requestHeartbeatNow: (opts?: { reason?: string }) => void;
|
requestHeartbeatNow: (opts?: { reason?: string }) => void;
|
||||||
runHeartbeatOnce?: (opts?: {
|
runHeartbeatOnce?: (opts?: {
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -74,6 +75,13 @@ function truncateText(input: string, maxLen: number) {
|
|||||||
return `${truncateUtf16Safe(input, Math.max(0, maxLen - 1)).trimEnd()}…`;
|
return `${truncateUtf16Safe(input, Math.max(0, maxLen - 1)).trimEnd()}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalAgentId(raw: unknown) {
|
||||||
|
if (typeof raw !== "string") return undefined;
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
return normalizeAgentId(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
function inferLegacyName(job: {
|
function inferLegacyName(job: {
|
||||||
schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown };
|
schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown };
|
||||||
payload?: { kind?: unknown; text?: unknown; message?: unknown };
|
payload?: { kind?: unknown; text?: unknown; message?: unknown };
|
||||||
@@ -181,6 +189,7 @@ export class CronService {
|
|||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const job: CronJob = {
|
const job: CronJob = {
|
||||||
id,
|
id,
|
||||||
|
agentId: normalizeOptionalAgentId(input.agentId),
|
||||||
name: normalizeRequiredName(input.name),
|
name: normalizeRequiredName(input.name),
|
||||||
description: normalizeOptionalText(input.description),
|
description: normalizeOptionalText(input.description),
|
||||||
enabled: input.enabled !== false,
|
enabled: input.enabled !== false,
|
||||||
@@ -226,6 +235,11 @@ export class CronService {
|
|||||||
if (patch.payload) job.payload = patch.payload;
|
if (patch.payload) job.payload = patch.payload;
|
||||||
if (patch.isolation) job.isolation = patch.isolation;
|
if (patch.isolation) job.isolation = patch.isolation;
|
||||||
if (patch.state) job.state = { ...job.state, ...patch.state };
|
if (patch.state) job.state = { ...job.state, ...patch.state };
|
||||||
|
if ("agentId" in patch) {
|
||||||
|
job.agentId = normalizeOptionalAgentId(
|
||||||
|
(patch as { agentId?: unknown }).agentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
job.updatedAtMs = now;
|
job.updatedAtMs = now;
|
||||||
this.assertSupportedJobSpec(job);
|
this.assertSupportedJobSpec(job);
|
||||||
@@ -495,7 +509,9 @@ export class CronService {
|
|||||||
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
||||||
const body = (summary ?? err ?? status).trim();
|
const body = (summary ?? err ?? status).trim();
|
||||||
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
||||||
this.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`);
|
this.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
|
||||||
|
agentId: job.agentId,
|
||||||
|
});
|
||||||
if (job.wakeMode === "now") {
|
if (job.wakeMode === "now") {
|
||||||
this.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
|
this.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
|
||||||
}
|
}
|
||||||
@@ -519,7 +535,7 @@ export class CronService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.deps.enqueueSystemEvent(text);
|
this.deps.enqueueSystemEvent(text, { agentId: job.agentId });
|
||||||
if (job.wakeMode === "now" && this.deps.runHeartbeatOnce) {
|
if (job.wakeMode === "now" && this.deps.runHeartbeatOnce) {
|
||||||
const reason = `cron:${job.id}`;
|
const reason = `cron:${job.id}`;
|
||||||
const delay = (ms: number) =>
|
const delay = (ms: number) =>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type CronJobState = {
|
|||||||
|
|
||||||
export type CronJob = {
|
export type CronJob = {
|
||||||
id: string;
|
id: string;
|
||||||
|
agentId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -826,6 +826,7 @@ export const CronJobStateSchema = Type.Object(
|
|||||||
export const CronJobSchema = Type.Object(
|
export const CronJobSchema = Type.Object(
|
||||||
{
|
{
|
||||||
id: NonEmptyString,
|
id: NonEmptyString,
|
||||||
|
agentId: Type.Optional(NonEmptyString),
|
||||||
name: NonEmptyString,
|
name: NonEmptyString,
|
||||||
description: Type.Optional(Type.String()),
|
description: Type.Optional(Type.String()),
|
||||||
enabled: Type.Boolean(),
|
enabled: Type.Boolean(),
|
||||||
@@ -856,6 +857,7 @@ export const CronStatusParamsSchema = Type.Object(
|
|||||||
export const CronAddParamsSchema = Type.Object(
|
export const CronAddParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
name: NonEmptyString,
|
name: NonEmptyString,
|
||||||
|
agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
description: Type.Optional(Type.String()),
|
description: Type.Optional(Type.String()),
|
||||||
enabled: Type.Optional(Type.Boolean()),
|
enabled: Type.Optional(Type.Boolean()),
|
||||||
schedule: CronScheduleSchema,
|
schedule: CronScheduleSchema,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
} from "../config/port-defaults.js";
|
} from "../config/port-defaults.js";
|
||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveMainSessionKey,
|
resolveAgentMainSessionKey,
|
||||||
resolveMainSessionKeyFromConfig,
|
resolveMainSessionKeyFromConfig,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
@@ -119,6 +119,7 @@ import {
|
|||||||
normalizeProviderId,
|
normalizeProviderId,
|
||||||
type ProviderId,
|
type ProviderId,
|
||||||
} from "../providers/plugins/index.js";
|
} from "../providers/plugins/index.js";
|
||||||
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
isGatewayCliClient,
|
isGatewayCliClient,
|
||||||
@@ -782,11 +783,36 @@ export async function startGatewayServer(
|
|||||||
const storePath = resolveCronStorePath(cfg.cron?.store);
|
const storePath = resolveCronStorePath(cfg.cron?.store);
|
||||||
const cronEnabled =
|
const cronEnabled =
|
||||||
process.env.CLAWDBOT_SKIP_CRON !== "1" && cfg.cron?.enabled !== false;
|
process.env.CLAWDBOT_SKIP_CRON !== "1" && cfg.cron?.enabled !== false;
|
||||||
|
const resolveCronAgent = (requested?: string | null) => {
|
||||||
|
const runtimeConfig = loadConfig();
|
||||||
|
const normalized =
|
||||||
|
typeof requested === "string" && requested.trim()
|
||||||
|
? normalizeAgentId(requested)
|
||||||
|
: undefined;
|
||||||
|
const hasAgent =
|
||||||
|
normalized !== undefined &&
|
||||||
|
Array.isArray(runtimeConfig.agents?.list) &&
|
||||||
|
runtimeConfig.agents.list.some(
|
||||||
|
(entry) =>
|
||||||
|
entry &&
|
||||||
|
typeof entry.id === "string" &&
|
||||||
|
normalizeAgentId(entry.id) === normalized,
|
||||||
|
);
|
||||||
|
const agentId = hasAgent
|
||||||
|
? normalized
|
||||||
|
: resolveDefaultAgentId(runtimeConfig);
|
||||||
|
return { agentId, cfg: runtimeConfig };
|
||||||
|
};
|
||||||
const cron = new CronService({
|
const cron = new CronService({
|
||||||
storePath,
|
storePath,
|
||||||
cronEnabled,
|
cronEnabled,
|
||||||
enqueueSystemEvent: (text) => {
|
enqueueSystemEvent: (text, opts) => {
|
||||||
enqueueSystemEvent(text, { sessionKey: resolveMainSessionKey(cfg) });
|
const { agentId, cfg: runtimeConfig } = resolveCronAgent(opts?.agentId);
|
||||||
|
const sessionKey = resolveAgentMainSessionKey({
|
||||||
|
cfg: runtimeConfig,
|
||||||
|
agentId,
|
||||||
|
});
|
||||||
|
enqueueSystemEvent(text, { sessionKey });
|
||||||
},
|
},
|
||||||
requestHeartbeatNow,
|
requestHeartbeatNow,
|
||||||
runHeartbeatOnce: async (opts) => {
|
runHeartbeatOnce: async (opts) => {
|
||||||
@@ -798,12 +824,13 @@ export async function startGatewayServer(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
runIsolatedAgentJob: async ({ job, message }) => {
|
runIsolatedAgentJob: async ({ job, message }) => {
|
||||||
const runtimeConfig = loadConfig();
|
const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
|
||||||
return await runCronIsolatedAgentTurn({
|
return await runCronIsolatedAgentTurn({
|
||||||
cfg: runtimeConfig,
|
cfg: runtimeConfig,
|
||||||
deps,
|
deps,
|
||||||
job,
|
job,
|
||||||
message,
|
message,
|
||||||
|
agentId,
|
||||||
sessionKey: `cron:${job.id}`,
|
sessionKey: `cron:${job.id}`,
|
||||||
lane: "cron",
|
lane: "cron",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ export type CronJobState = {
|
|||||||
|
|
||||||
export type CronJob = {
|
export type CronJob = {
|
||||||
id: string;
|
id: string;
|
||||||
|
agentId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user