Skip to content

Commit 115591c

Browse files
committed
feat: add cron agent binding
1 parent a3938d6 commit 115591c

14 files changed

Lines changed: 383 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,9 @@
11
# Changelog
22

3-
# 2026.1.12-1
3+
## 2026.1.12
44

55
### Changes
6-
- Heartbeat: raise default `ackMaxChars` to 300 so any `HEARTBEAT_OK` replies with short padding stay internal (fewer noisy heartbeat posts on providers).
7-
8-
## 2026.1.11-5
9-
10-
### Fixes
11-
- Auto-reply: prevent duplicate /status replies (including /usage alias) and add tests for inline + standalone cases.
12-
13-
## 2026.1.11-4
14-
15-
### Fixes
16-
- CLI: read the git commit hash from the package root so npm installs show it.
17-
18-
## 2026.1.11-3
19-
20-
### Fixes
21-
- CLI: avoid top-level await warnings in the entrypoint on fresh installs.
22-
- CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).
23-
24-
## 2026.1.11-2
25-
26-
### Fixes
27-
- Installer: ensure the CLI entrypoint is executable after npm installs.
28-
- Packaging: include `dist/plugins/` in the npm package to avoid missing module errors.
29-
30-
## 2026.1.11-1
31-
32-
### Fixes
33-
- Installer: include `patches/` in the npm package so postinstall patching works for npm/bun installs.
6+
- 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)
347

358
## 2026.1.11
369

docs/automation/cron-jobs.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ A cron job is a stored record with:
2727
- a **schedule** (when it should run),
2828
- a **payload** (what it should do),
2929
- optional **delivery** (where output should be sent).
30+
- optional **agent binding** (`agentId`): run the job under a specific agent; if
31+
missing or unknown, the gateway falls back to the default agent.
3032

3133
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
3234
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
@@ -190,6 +192,16 @@ clawdbot cron add \
190192
--deliver \
191193
--provider whatsapp \
192194
--to "+15551234567"
195+
196+
Agent selection (multi-agent setups):
197+
```bash
198+
# Pin a job to agent "ops" (falls back to default if that agent is missing)
199+
clawdbot cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
200+
201+
# Switch or clear the agent on an existing job
202+
clawdbot cron edit <jobId> --agent ops
203+
clawdbot cron edit <jobId> --clear-agent
204+
```
193205
```
194206

195207
Manual run (debug):

src/cli/cron-cli.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,39 @@ describe("cron cli", () => {
7474
expect(params?.payload?.thinking).toBe("low");
7575
});
7676

77+
it("sends agent id on cron add", async () => {
78+
callGatewayFromCli.mockClear();
79+
80+
const { registerCronCli } = await import("./cron-cli.js");
81+
const program = new Command();
82+
program.exitOverride();
83+
registerCronCli(program);
84+
85+
await program.parseAsync(
86+
[
87+
"cron",
88+
"add",
89+
"--name",
90+
"Agent pinned",
91+
"--cron",
92+
"* * * * *",
93+
"--session",
94+
"isolated",
95+
"--message",
96+
"hi",
97+
"--agent",
98+
"ops",
99+
],
100+
{ from: "user" },
101+
);
102+
103+
const addCall = callGatewayFromCli.mock.calls.find(
104+
(call) => call[0] === "cron.add",
105+
);
106+
const params = addCall?.[2] as { agentId?: string };
107+
expect(params?.agentId).toBe("ops");
108+
});
109+
77110
it("omits empty model and thinking on cron edit", async () => {
78111
callGatewayFromCli.mockClear();
79112

@@ -142,6 +175,36 @@ describe("cron cli", () => {
142175
expect(patch?.patch?.payload?.thinking).toBe("high");
143176
});
144177

178+
it("sets and clears agent id on cron edit", async () => {
179+
callGatewayFromCli.mockClear();
180+
181+
const { registerCronCli } = await import("./cron-cli.js");
182+
const program = new Command();
183+
program.exitOverride();
184+
registerCronCli(program);
185+
186+
await program.parseAsync(
187+
["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"],
188+
{ from: "user" },
189+
);
190+
191+
const updateCall = callGatewayFromCli.mock.calls.find(
192+
(call) => call[0] === "cron.update",
193+
);
194+
const patch = updateCall?.[2] as { patch?: { agentId?: unknown } };
195+
expect(patch?.patch?.agentId).toBe("Ops");
196+
197+
callGatewayFromCli.mockClear();
198+
await program.parseAsync(["cron", "edit", "job-2", "--clear-agent"], {
199+
from: "user",
200+
});
201+
const clearCall = callGatewayFromCli.mock.calls.find(
202+
(call) => call[0] === "cron.update",
203+
);
204+
const clearPatch = clearCall?.[2] as { patch?: { agentId?: unknown } };
205+
expect(clearPatch?.patch?.agentId).toBeNull();
206+
});
207+
145208
it("does not include model/thinking when no payload change is requested", async () => {
146209
callGatewayFromCli.mockClear();
147210

src/cli/cron-cli.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Command } from "commander";
22
import type { CronJob, CronSchedule } from "../cron/types.js";
33
import { danger } from "../globals.js";
44
import { PROVIDER_IDS } from "../providers/registry.js";
5+
import { normalizeAgentId } from "../routing/session-key.js";
56
import { defaultRuntime } from "../runtime.js";
67
import { formatDocsLink } from "../terminal/links.js";
78
import { colorize, isRich, theme } from "../terminal/theme.js";
@@ -72,6 +73,7 @@ const CRON_NEXT_PAD = 10;
7273
const CRON_LAST_PAD = 10;
7374
const CRON_STATUS_PAD = 9;
7475
const CRON_TARGET_PAD = 9;
76+
const CRON_AGENT_PAD = 10;
7577

7678
const pad = (value: string, width: number) => value.padEnd(width);
7779

@@ -139,6 +141,7 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
139141
pad("Last", CRON_LAST_PAD),
140142
pad("Status", CRON_STATUS_PAD),
141143
pad("Target", CRON_TARGET_PAD),
144+
pad("Agent", CRON_AGENT_PAD),
142145
].join(" ");
143146

144147
runtime.log(rich ? theme.heading(header) : header);
@@ -162,6 +165,10 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
162165
const statusRaw = formatStatus(job);
163166
const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
164167
const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD);
168+
const agentLabel = pad(
169+
truncate(job.agentId ?? "default", CRON_AGENT_PAD),
170+
CRON_AGENT_PAD,
171+
);
165172

166173
const coloredStatus = (() => {
167174
if (statusRaw === "ok") return colorize(rich, theme.success, statusLabel);
@@ -178,6 +185,9 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
178185
job.sessionTarget === "isolated"
179186
? colorize(rich, theme.accentBright, targetLabel)
180187
: colorize(rich, theme.accent, targetLabel);
188+
const coloredAgent = job.agentId
189+
? colorize(rich, theme.info, agentLabel)
190+
: colorize(rich, theme.muted, agentLabel);
181191

182192
const line = [
183193
colorize(rich, theme.accent, idLabel),
@@ -187,6 +197,7 @@ function printCronList(jobs: CronJob[], runtime = defaultRuntime) {
187197
colorize(rich, theme.muted, lastLabel),
188198
coloredStatus,
189199
coloredTarget,
200+
coloredAgent,
190201
].join(" ");
191202

192203
runtime.log(line.trimEnd());
@@ -283,6 +294,7 @@ export function registerCronCli(program: Command) {
283294
.requiredOption("--name <name>", "Job name")
284295
.option("--description <text>", "Optional description")
285296
.option("--disabled", "Create job disabled", false)
297+
.option("--agent <id>", "Agent id for this job")
286298
.option("--session <target>", "Session target (main|isolated)", "main")
287299
.option(
288300
"--wake <mode>",
@@ -375,6 +387,11 @@ export function registerCronCli(program: Command) {
375387
throw new Error("--wake must be now or next-heartbeat");
376388
}
377389

390+
const agentId =
391+
typeof opts.agent === "string" && opts.agent.trim()
392+
? normalizeAgentId(opts.agent)
393+
: undefined;
394+
378395
const payload = (() => {
379396
const systemEvent =
380397
typeof opts.systemEvent === "string"
@@ -451,6 +468,7 @@ export function registerCronCli(program: Command) {
451468
name,
452469
description,
453470
enabled: !opts.disabled,
471+
agentId,
454472
schedule,
455473
sessionTarget,
456474
wakeMode,
@@ -561,6 +579,8 @@ export function registerCronCli(program: Command) {
561579
.option("--enable", "Enable job", false)
562580
.option("--disable", "Disable job", false)
563581
.option("--session <target>", "Session target (main|isolated)")
582+
.option("--agent <id>", "Set agent id")
583+
.option("--clear-agent", "Unset agent and use default", false)
564584
.option("--wake <mode>", "Wake mode (now|next-heartbeat)")
565585
.option("--at <when>", "Set one-shot time (ISO) or duration like 20m")
566586
.option("--every <duration>", "Set interval duration like 10m")
@@ -613,6 +633,15 @@ export function registerCronCli(program: Command) {
613633
if (typeof opts.session === "string")
614634
patch.sessionTarget = opts.session;
615635
if (typeof opts.wake === "string") patch.wakeMode = opts.wake;
636+
if (opts.agent && opts.clearAgent) {
637+
throw new Error("Use --agent or --clear-agent, not both");
638+
}
639+
if (typeof opts.agent === "string" && opts.agent.trim()) {
640+
patch.agentId = normalizeAgentId(opts.agent);
641+
}
642+
if (opts.clearAgent) {
643+
patch.agentId = null;
644+
}
616645

617646
const scheduleChosen = [opts.at, opts.every, opts.cron].filter(
618647
Boolean,

src/cron/isolated-agent.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,75 @@ describe("runCronIsolatedAgentTurn", () => {
120120
});
121121
});
122122

123+
it("uses agentId for workspace, session key, and store paths", async () => {
124+
await withTempHome(async (home) => {
125+
const deps: CliDeps = {
126+
sendMessageWhatsApp: vi.fn(),
127+
sendMessageTelegram: vi.fn(),
128+
sendMessageDiscord: vi.fn(),
129+
sendMessageSignal: vi.fn(),
130+
sendMessageIMessage: vi.fn(),
131+
};
132+
const opsWorkspace = path.join(home, "ops-workspace");
133+
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
134+
payloads: [{ text: "ok" }],
135+
meta: {
136+
durationMs: 5,
137+
agentMeta: { sessionId: "s", provider: "p", model: "m" },
138+
},
139+
});
140+
141+
const cfg = makeCfg(
142+
home,
143+
path.join(
144+
home,
145+
".clawdbot",
146+
"agents",
147+
"{agentId}",
148+
"sessions",
149+
"sessions.json",
150+
),
151+
{
152+
agents: {
153+
defaults: { workspace: path.join(home, "default-workspace") },
154+
list: [
155+
{ id: "main", default: true },
156+
{ id: "ops", workspace: opsWorkspace },
157+
],
158+
},
159+
},
160+
);
161+
162+
const res = await runCronIsolatedAgentTurn({
163+
cfg,
164+
deps,
165+
job: {
166+
...makeJob({
167+
kind: "agentTurn",
168+
message: "do it",
169+
deliver: false,
170+
provider: "last",
171+
}),
172+
agentId: "ops",
173+
},
174+
message: "do it",
175+
sessionKey: "cron:job-ops",
176+
agentId: "ops",
177+
lane: "cron",
178+
});
179+
180+
expect(res.status).toBe("ok");
181+
const call = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0] as {
182+
sessionKey?: string;
183+
workspaceDir?: string;
184+
sessionFile?: string;
185+
};
186+
expect(call?.sessionKey).toBe("agent:ops:cron:job-ops");
187+
expect(call?.workspaceDir).toBe(opsWorkspace);
188+
expect(call?.sessionFile).toContain(path.join("agents", "ops"));
189+
});
190+
});
191+
123192
it("uses model override when provided", async () => {
124193
await withTempHome(async (home) => {
125194
const storePath = await writeSessionStore(home);

0 commit comments

Comments
 (0)