fix: normalize heartbeat targets
This commit is contained in:
@@ -32,6 +32,7 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Fixes
|
||||
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
|
||||
- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints.
|
||||
- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3)
|
||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
||||
|
||||
@@ -82,7 +82,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
every: "30m", // default: 30m (0m disables)
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
||||
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
||||
target: "last", // last | none | <channel id> (core or plugin, e.g. "bluebubbles")
|
||||
to: "+15551234567", // optional channel-specific override
|
||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
|
||||
@@ -138,7 +139,9 @@ export function registerCronAddCommand(cron: Command) {
|
||||
}
|
||||
|
||||
const agentId =
|
||||
typeof opts.agent === "string" && opts.agent.trim() ? opts.agent.trim() : undefined;
|
||||
typeof opts.agent === "string" && opts.agent.trim()
|
||||
? normalizeAgentId(opts.agent.trim())
|
||||
: undefined;
|
||||
|
||||
const payload = (() => {
|
||||
const systemEvent = typeof opts.systemEvent === "string" ? opts.systemEvent.trim() : "";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
@@ -90,7 +91,7 @@ export function registerCronEditCommand(cron: Command) {
|
||||
throw new Error("Use --agent or --clear-agent, not both");
|
||||
}
|
||||
if (typeof opts.agent === "string" && opts.agent.trim()) {
|
||||
patch.agentId = opts.agent.trim();
|
||||
patch.agentId = normalizeAgentId(opts.agent.trim());
|
||||
}
|
||||
if (opts.clearAgent) {
|
||||
patch.agentId = null;
|
||||
|
||||
@@ -9,6 +9,7 @@ async function writePluginFixture(params: {
|
||||
dir: string;
|
||||
id: string;
|
||||
schema: Record<string, unknown>;
|
||||
channels?: string[];
|
||||
}) {
|
||||
await fs.mkdir(params.dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
@@ -16,16 +17,16 @@ async function writePluginFixture(params: {
|
||||
`export default { id: "${params.id}", register() {} };`,
|
||||
"utf-8",
|
||||
);
|
||||
const manifest: Record<string, unknown> = {
|
||||
id: params.id,
|
||||
configSchema: params.schema,
|
||||
};
|
||||
if (params.channels) {
|
||||
manifest.channels = params.channels;
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(params.dir, "clawdbot.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: params.id,
|
||||
configSchema: params.schema,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
@@ -149,4 +150,43 @@ describe("config plugin validation", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts plugin heartbeat targets", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||
const pluginDir = path.join(home, "bluebubbles-plugin");
|
||||
await writePluginFixture({
|
||||
dir: pluginDir,
|
||||
id: "bluebubbles-plugin",
|
||||
channels: ["bluebubbles"],
|
||||
schema: { type: "object" },
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { validateConfigObjectWithPlugins } = await import("./config.js");
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] },
|
||||
plugins: { enabled: false, load: { paths: [pluginDir] } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown heartbeat targets", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||
vi.resetModules();
|
||||
const { validateConfigObjectWithPlugins } = await import("./config.js");
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues).toContainEqual({
|
||||
path: "agents.defaults.heartbeat.target",
|
||||
message: "unknown heartbeat target: not-a-channel",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,4 +84,22 @@ describe("config schema", () => {
|
||||
const channelProps = channelSchema?.properties as Record<string, unknown> | undefined;
|
||||
expect(channelProps?.accessToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds heartbeat target hints with dynamic channels", () => {
|
||||
const res = buildConfigSchema({
|
||||
channels: [
|
||||
{
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const defaultsHint = res.uiHints["agents.defaults.heartbeat.target"];
|
||||
const listHint = res.uiHints["agents.list.*.heartbeat.target"];
|
||||
expect(defaultsHint?.help).toContain("bluebubbles");
|
||||
expect(defaultsHint?.help).toContain("last");
|
||||
expect(listHint?.help).toContain("bluebubbles");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { ClawdbotSchema } from "./zod-schema.js";
|
||||
|
||||
@@ -807,6 +808,44 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]):
|
||||
return next;
|
||||
}
|
||||
|
||||
function listHeartbeatTargetChannels(channels: ChannelUiMetadata[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const id of CHANNEL_IDS) {
|
||||
const normalized = id.trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
ordered.push(normalized);
|
||||
}
|
||||
for (const channel of channels) {
|
||||
const normalized = channel.id.trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
ordered.push(normalized);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function applyHeartbeatTargetHints(
|
||||
hints: ConfigUiHints,
|
||||
channels: ChannelUiMetadata[],
|
||||
): ConfigUiHints {
|
||||
const next: ConfigUiHints = { ...hints };
|
||||
const channelList = listHeartbeatTargetChannels(channels);
|
||||
const channelHelp = channelList.length ? ` Known channels: ${channelList.join(", ")}.` : "";
|
||||
const help = `Delivery target ("last", "none", or a channel id).${channelHelp}`;
|
||||
const paths = ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"];
|
||||
for (const path of paths) {
|
||||
const current = next[path] ?? {};
|
||||
next[path] = {
|
||||
...current,
|
||||
help: current.help ?? help,
|
||||
placeholder: current.placeholder ?? "last",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
@@ -908,7 +947,10 @@ export function buildConfigSchema(params?: {
|
||||
const channels = params?.channels ?? [];
|
||||
if (plugins.length === 0 && channels.length === 0) return base;
|
||||
const mergedHints = applySensitiveHints(
|
||||
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
|
||||
applyHeartbeatTargetHints(
|
||||
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
|
||||
channels,
|
||||
),
|
||||
);
|
||||
const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels);
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
HumanDelayConfig,
|
||||
TypingMode,
|
||||
} from "./types.base.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type {
|
||||
SandboxBrowserSettings,
|
||||
SandboxDockerSettings,
|
||||
@@ -177,18 +178,8 @@ export type AgentDefaultsConfig = {
|
||||
model?: string;
|
||||
/** Session key for heartbeat runs ("main" or explicit session key). */
|
||||
session?: string;
|
||||
/** Delivery target (last|whatsapp|telegram|discord|slack|mattermost|msteams|signal|imessage|none). */
|
||||
target?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "mattermost"
|
||||
| "msteams"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "none";
|
||||
/** Delivery target ("last", "none", or a channel id). */
|
||||
target?: "last" | "none" | ChannelId;
|
||||
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||
to?: string;
|
||||
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
|
||||
import {
|
||||
normalizePluginsConfig,
|
||||
resolveEnableState,
|
||||
@@ -226,6 +226,41 @@ export function validateConfigObjectWithPlugins(raw: unknown):
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeatChannelIds = new Set<string>();
|
||||
for (const channelId of CHANNEL_IDS) {
|
||||
heartbeatChannelIds.add(channelId.toLowerCase());
|
||||
}
|
||||
for (const record of registry.plugins) {
|
||||
for (const channelId of record.channels) {
|
||||
const trimmed = channelId.trim();
|
||||
if (trimmed) heartbeatChannelIds.add(trimmed.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
const validateHeartbeatTarget = (target: string | undefined, path: string) => {
|
||||
if (typeof target !== "string") return;
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) {
|
||||
issues.push({ path, message: "heartbeat target must not be empty" });
|
||||
return;
|
||||
}
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === "last" || normalized === "none") return;
|
||||
if (normalizeChatChannelId(trimmed)) return;
|
||||
if (heartbeatChannelIds.has(normalized)) return;
|
||||
issues.push({ path, message: `unknown heartbeat target: ${target}` });
|
||||
};
|
||||
|
||||
validateHeartbeatTarget(
|
||||
config.agents?.defaults?.heartbeat?.target,
|
||||
"agents.defaults.heartbeat.target",
|
||||
);
|
||||
if (Array.isArray(config.agents?.list)) {
|
||||
for (const [index, entry] of config.agents.list.entries()) {
|
||||
validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`);
|
||||
}
|
||||
}
|
||||
|
||||
let selectedMemoryPluginId: string | null = null;
|
||||
const seenPlugins = new Set<string>();
|
||||
for (const record of registry.plugins) {
|
||||
|
||||
@@ -22,19 +22,7 @@ export const HeartbeatSchema = z
|
||||
model: z.string().optional(),
|
||||
session: z.string().optional(),
|
||||
includeReasoning: z.boolean().optional(),
|
||||
target: z
|
||||
.union([
|
||||
z.literal("last"),
|
||||
z.literal("whatsapp"),
|
||||
z.literal("telegram"),
|
||||
z.literal("discord"),
|
||||
z.literal("slack"),
|
||||
z.literal("msteams"),
|
||||
z.literal("signal"),
|
||||
z.literal("imessage"),
|
||||
z.literal("none"),
|
||||
])
|
||||
.optional(),
|
||||
target: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
ackMaxChars: z.number().int().nonnegative().optional(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||
import { migrateLegacyCronPayload } from "./payload-migration.js";
|
||||
import type { CronJobCreate, CronJobPatch } from "./types.js";
|
||||
@@ -75,7 +76,7 @@ export function normalizeCronJobInput(
|
||||
next.agentId = null;
|
||||
} else if (typeof agentId === "string") {
|
||||
const trimmed = agentId.trim();
|
||||
if (trimmed) next.agentId = trimmed;
|
||||
if (trimmed) next.agentId = normalizeAgentId(trimmed);
|
||||
else delete next.agentId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ function formatDiscordDeployErrorDetails(err: unknown): string {
|
||||
try {
|
||||
bodyText = JSON.stringify(rawBody);
|
||||
} catch {
|
||||
bodyText = inspect(rawBody, { depth: 3 });
|
||||
bodyText =
|
||||
typeof rawBody === "string" ? rawBody : inspect(rawBody, { depth: 3, breakLength: 120 });
|
||||
}
|
||||
if (bodyText) {
|
||||
const maxLen = 800;
|
||||
|
||||
@@ -96,9 +96,9 @@ function pickFirstExistingAgentId(cfg: ClawdbotConfig, agentId: string): string
|
||||
if (!trimmed) return normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
const normalized = normalizeAgentId(trimmed);
|
||||
const agents = listAgents(cfg);
|
||||
if (agents.length === 0) return trimmed;
|
||||
if (agents.length === 0) return normalized;
|
||||
const match = agents.find((agent) => normalizeAgentId(agent.id) === normalized);
|
||||
if (match?.id?.trim()) return match.id.trim();
|
||||
if (match?.id?.trim()) return normalizeAgentId(match.id.trim());
|
||||
return normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user