fix: finish model list alias + heartbeat session (#1256) (thanks @zknicker)
This commit is contained in:
@@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe("directive behavior", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves /model list to /models", async () => {
|
it("aliases /model list to /models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
@@ -84,13 +84,15 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model listing moved.");
|
expect(text).toContain("Providers:");
|
||||||
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
expect(text).toContain("- anthropic");
|
||||||
|
expect(text).toContain("- openai");
|
||||||
|
expect(text).toContain("Use: /models <provider>");
|
||||||
expect(text).toContain("Switch: /model <provider/model>");
|
expect(text).toContain("Switch: /model <provider/model>");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("shows summary on /model when catalog is unavailable", async () => {
|
it("shows current model when catalog is unavailable", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
|
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
|
||||||
@@ -122,10 +124,10 @@ describe("directive behavior", () => {
|
|||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("moves /model list to /models even when no allowlist is set", async () => {
|
it("includes catalog providers when no allowlist is set", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
vi.mocked(loadModelCatalog).mockResolvedValue([
|
||||||
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
|
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
|
||||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
||||||
{ id: "grok-4", name: "Grok 4", provider: "xai" },
|
{ id: "grok-4", name: "Grok 4", provider: "xai" },
|
||||||
@@ -151,13 +153,15 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model listing moved.");
|
expect(text).toContain("Providers:");
|
||||||
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
expect(text).toContain("- anthropic");
|
||||||
expect(text).toContain("Switch: /model <provider/model>");
|
expect(text).toContain("- openai");
|
||||||
|
expect(text).toContain("- xai");
|
||||||
|
expect(text).toContain("Use: /models <provider>");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("moves /model list to /models even when catalog is present", async () => {
|
it("lists config-only providers when catalog is present", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
// Catalog present but missing custom providers: /model should still include
|
// Catalog present but missing custom providers: /model should still include
|
||||||
@@ -173,7 +177,7 @@ describe("directive behavior", () => {
|
|||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -202,13 +206,12 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model listing moved.");
|
expect(text).toContain("Model set to minimax");
|
||||||
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
expect(text).toContain("minimax/MiniMax-M2.1");
|
||||||
expect(text).toContain("Switch: /model <provider/model>");
|
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("moves /model list to /models without listing auth labels", async () => {
|
it("does not repeat missing auth labels on /model list", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
@@ -231,9 +234,7 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model listing moved.");
|
expect(text).toContain("Providers:");
|
||||||
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
|
||||||
expect(text).toContain("Switch: /model <provider/model>");
|
|
||||||
expect(text).not.toContain("missing (missing)");
|
expect(text).not.toContain("missing (missing)");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ describe("directive behavior", () => {
|
|||||||
expect(text).toContain("Switch: /model <provider/model>");
|
expect(text).toContain("Switch: /model <provider/model>");
|
||||||
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||||
expect(text).toContain("More: /model status");
|
expect(text).toContain("More: /model status");
|
||||||
|
expect(text).not.toContain("openai/gpt-4.1-mini");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ describe("trigger handling", () => {
|
|||||||
expect(normalized).not.toContain("image");
|
expect(normalized).not.toContain("image");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("moves /model list to /models", async () => {
|
it("aliases /model list to /models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
@@ -143,8 +143,8 @@ describe("trigger handling", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
const normalized = normalizeTestText(text ?? "");
|
const normalized = normalizeTestText(text ?? "");
|
||||||
expect(normalized).toContain("Model listing moved.");
|
expect(normalized).toContain("Providers:");
|
||||||
expect(normalized).toContain("Use: /models (providers) or /models <provider> (models)");
|
expect(normalized).toContain("Use: /models <provider>");
|
||||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
resolveModelRefFromString,
|
resolveModelRefFromString,
|
||||||
} from "../../agents/model-selection.js";
|
} from "../../agents/model-selection.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import type { CommandHandler } from "./commands-types.js";
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
|
|
||||||
@@ -68,10 +69,11 @@ function parseModelsArgs(raw: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => {
|
export async function resolveModelsCommandReply(params: {
|
||||||
if (!allowTextCommands) return null;
|
cfg: ClawdbotConfig;
|
||||||
|
commandBodyNormalized: string;
|
||||||
const body = params.command.commandBodyNormalized.trim();
|
}): Promise<ReplyPayload | null> {
|
||||||
|
const body = params.commandBodyNormalized.trim();
|
||||||
if (!body.startsWith("/models")) return null;
|
if (!body.startsWith("/models")) return null;
|
||||||
|
|
||||||
const argText = body.replace(/^\/models\b/i, "").trim();
|
const argText = body.replace(/^\/models\b/i, "").trim();
|
||||||
@@ -164,7 +166,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
"Use: /models <provider>",
|
"Use: /models <provider>",
|
||||||
"Switch: /model <provider/model>",
|
"Switch: /model <provider/model>",
|
||||||
];
|
];
|
||||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!byProvider.has(provider)) {
|
if (!byProvider.has(provider)) {
|
||||||
@@ -176,7 +178,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
"",
|
"",
|
||||||
"Use: /models <provider>",
|
"Use: /models <provider>",
|
||||||
];
|
];
|
||||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = [...(byProvider.get(provider) ?? new Set<string>())].sort();
|
const models = [...(byProvider.get(provider) ?? new Set<string>())].sort();
|
||||||
@@ -189,7 +191,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
"Browse: /models",
|
"Browse: /models",
|
||||||
"Switch: /model <provider/model>",
|
"Switch: /model <provider/model>",
|
||||||
];
|
];
|
||||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectivePageSize = all ? total : pageSize;
|
const effectivePageSize = all ? total : pageSize;
|
||||||
@@ -203,7 +205,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
`Try: /models ${provider} ${safePage}`,
|
`Try: /models ${provider} ${safePage}`,
|
||||||
`All: /models ${provider} all`,
|
`All: /models ${provider} all`,
|
||||||
];
|
];
|
||||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
|
|
||||||
const startIndex = (safePage - 1) * effectivePageSize;
|
const startIndex = (safePage - 1) * effectivePageSize;
|
||||||
@@ -226,5 +228,16 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload: ReplyPayload = { text: lines.join("\n") };
|
const payload: ReplyPayload = { text: lines.join("\n") };
|
||||||
return { reply: payload, shouldContinue: false };
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||||
|
if (!allowTextCommands) return null;
|
||||||
|
|
||||||
|
const reply = await resolveModelsCommandReply({
|
||||||
|
cfg: params.cfg,
|
||||||
|
commandBodyNormalized: params.command.commandBodyNormalized,
|
||||||
|
});
|
||||||
|
if (!reply) return null;
|
||||||
|
return { reply, shouldContinue: false };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("/model chat UX", () => {
|
|||||||
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("suggests closest match for typos without switching", () => {
|
it("auto-applies closest match for typos", () => {
|
||||||
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
||||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
@@ -52,9 +52,11 @@ describe("/model chat UX", () => {
|
|||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolved.modelSelection).toBeUndefined();
|
expect(resolved.modelSelection).toEqual({
|
||||||
expect(resolved.errorText).toContain("Did you mean:");
|
provider: "anthropic",
|
||||||
expect(resolved.errorText).toContain("anthropic/claude-opus-4-5");
|
model: "claude-opus-4-5",
|
||||||
expect(resolved.errorText).toContain("Try: /model anthropic/claude-opus-4-5");
|
isDefault: true,
|
||||||
|
});
|
||||||
|
expect(resolved.errorText).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
resolveProviderEndpointLabel,
|
resolveProviderEndpointLabel,
|
||||||
} from "./directive-handling.model-picker.js";
|
} from "./directive-handling.model-picker.js";
|
||||||
import type { InlineDirectives } from "./directive-handling.parse.js";
|
import type { InlineDirectives } from "./directive-handling.parse.js";
|
||||||
|
import { resolveModelsCommandReply } from "./commands-models.js";
|
||||||
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
|
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
|
||||||
|
|
||||||
function buildModelPickerCatalog(params: {
|
function buildModelPickerCatalog(params: {
|
||||||
@@ -185,14 +186,11 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (wantsLegacyList) {
|
if (wantsLegacyList) {
|
||||||
return {
|
const reply = await resolveModelsCommandReply({
|
||||||
text: [
|
cfg: params.cfg,
|
||||||
"Model listing moved.",
|
commandBodyNormalized: "/models",
|
||||||
"",
|
});
|
||||||
"Use: /models (providers) or /models <provider> (models)",
|
return reply ?? { text: "No models available." };
|
||||||
"Switch: /model <provider/model>",
|
|
||||||
].join("\n"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wantsSummary) {
|
if (wantsSummary) {
|
||||||
@@ -340,42 +338,7 @@ export function resolveModelSelectionFromDirective(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolved.selection) {
|
if (resolved.selection) {
|
||||||
const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`;
|
modelSelection = resolved.selection;
|
||||||
const rawHasSlash = raw.includes("/");
|
|
||||||
const shouldAutoSelect = (() => {
|
|
||||||
if (!rawHasSlash) return true;
|
|
||||||
const slash = raw.indexOf("/");
|
|
||||||
if (slash <= 0) return true;
|
|
||||||
const rawProvider = normalizeProviderId(raw.slice(0, slash));
|
|
||||||
const rawFragment = raw
|
|
||||||
.slice(slash + 1)
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!rawFragment) return false;
|
|
||||||
const resolvedProvider = normalizeProviderId(resolved.selection.provider);
|
|
||||||
if (rawProvider !== resolvedProvider) return false;
|
|
||||||
const resolvedModel = resolved.selection.model.toLowerCase();
|
|
||||||
return (
|
|
||||||
resolvedModel.startsWith(rawFragment) ||
|
|
||||||
resolvedModel.includes(rawFragment) ||
|
|
||||||
rawFragment.startsWith(resolvedModel)
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (shouldAutoSelect) {
|
|
||||||
modelSelection = resolved.selection;
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
errorText: [
|
|
||||||
`Unrecognized model: ${raw}`,
|
|
||||||
"",
|
|
||||||
`Did you mean: ${suggestion}`,
|
|
||||||
`Try: /model ${suggestion}`,
|
|
||||||
"",
|
|
||||||
"Browse: /models or /models <provider>",
|
|
||||||
].join("\n"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -478,7 +478,9 @@ describe("runHeartbeatOnce", () => {
|
|||||||
peerKind: "group",
|
peerKind: "group",
|
||||||
peerId: groupId,
|
peerId: groupId,
|
||||||
});
|
});
|
||||||
cfg.agents?.defaults?.heartbeat && (cfg.agents.defaults.heartbeat.session = groupSessionKey);
|
if (cfg.agents?.defaults?.heartbeat) {
|
||||||
|
cfg.agents.defaults.heartbeat.session = groupSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
storePath,
|
storePath,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
|
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||||
@@ -14,8 +15,8 @@ import { parseDurationMs } from "../cli/parse-duration.js";
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
loadSessionStore,
|
|
||||||
canonicalizeMainSessionAlias,
|
canonicalizeMainSessionAlias,
|
||||||
|
loadSessionStore,
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
resolveAgentMainSessionKey,
|
resolveAgentMainSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
@@ -26,6 +27,7 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
|||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { getQueueSize } from "../process/command-queue.js";
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
|
import { CommandLane } from "../process/lanes.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
|
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
|
||||||
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
||||||
@@ -70,6 +72,94 @@ export type HeartbeatSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_HEARTBEAT_TARGET = "last";
|
const DEFAULT_HEARTBEAT_TARGET = "last";
|
||||||
|
const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/;
|
||||||
|
|
||||||
|
function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
|
||||||
|
const trimmed = raw?.trim();
|
||||||
|
if (!trimmed || trimmed === "user") {
|
||||||
|
return resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
|
||||||
|
}
|
||||||
|
if (trimmed === "local") {
|
||||||
|
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
return host?.trim() || "UTC";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
|
||||||
|
return trimmed;
|
||||||
|
} catch {
|
||||||
|
return resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null {
|
||||||
|
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
|
||||||
|
const [hourStr, minuteStr] = raw.split(":");
|
||||||
|
const hour = Number(hourStr);
|
||||||
|
const minute = Number(minuteStr);
|
||||||
|
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||||
|
if (hour === 24) {
|
||||||
|
if (!opts.allow24 || minute !== 0) return null;
|
||||||
|
return 24 * 60;
|
||||||
|
}
|
||||||
|
return hour * 60 + minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMinutesInTimeZone(nowMs: number, timeZone: string): number | null {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).formatToParts(new Date(nowMs));
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type !== "literal") map[part.type] = part.value;
|
||||||
|
}
|
||||||
|
const hour = Number(map.hour);
|
||||||
|
const minute = Number(map.minute);
|
||||||
|
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
|
||||||
|
return hour * 60 + minute;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinActiveHours(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
heartbeat?: HeartbeatConfig,
|
||||||
|
nowMs?: number,
|
||||||
|
): boolean {
|
||||||
|
const active = heartbeat?.activeHours;
|
||||||
|
if (!active) return true;
|
||||||
|
|
||||||
|
const startMin = parseActiveHoursTime({ allow24: false }, active.start);
|
||||||
|
const endMin = parseActiveHoursTime({ allow24: true }, active.end);
|
||||||
|
if (startMin === null || endMin === null) return true;
|
||||||
|
if (startMin === endMin) return true;
|
||||||
|
|
||||||
|
const timeZone = resolveActiveHoursTimezone(cfg, active.timezone);
|
||||||
|
const currentMin = resolveMinutesInTimeZone(nowMs ?? Date.now(), timeZone);
|
||||||
|
if (currentMin === null) return true;
|
||||||
|
|
||||||
|
if (endMin > startMin) {
|
||||||
|
return currentMin >= startMin && currentMin < endMin;
|
||||||
|
}
|
||||||
|
return currentMin >= startMin || currentMin < endMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeartbeatAgentState = {
|
||||||
|
agentId: string;
|
||||||
|
heartbeat?: HeartbeatConfig;
|
||||||
|
intervalMs: number;
|
||||||
|
lastRunMs?: number;
|
||||||
|
nextDueMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeartbeatRunner = {
|
||||||
|
stop: () => void;
|
||||||
|
updateConfig: (cfg: ClawdbotConfig) => void;
|
||||||
|
};
|
||||||
|
|
||||||
function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) {
|
function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) {
|
||||||
const list = cfg.agents?.list ?? [];
|
const list = cfg.agents?.list ?? [];
|
||||||
@@ -365,12 +455,16 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
return { status: "skipped", reason: "disabled" };
|
return { status: "skipped", reason: "disabled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main");
|
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
||||||
|
if (!isWithinActiveHours(cfg, heartbeat, startedAt)) {
|
||||||
|
return { status: "skipped", reason: "quiet-hours" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)(CommandLane.Main);
|
||||||
if (queueSize > 0) {
|
if (queueSize > 0) {
|
||||||
return { status: "skipped", reason: "requests-in-flight" };
|
return { status: "skipped", reason: "requests-in-flight" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
|
|
||||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
||||||
const previousUpdatedAt = entry?.updatedAt;
|
const previousUpdatedAt = entry?.updatedAt;
|
||||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||||
@@ -576,24 +670,97 @@ export function startHeartbeatRunner(opts: {
|
|||||||
cfg?: ClawdbotConfig;
|
cfg?: ClawdbotConfig;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
}) {
|
runOnce?: typeof runHeartbeatOnce;
|
||||||
const cfg = opts.cfg ?? loadConfig();
|
}): HeartbeatRunner {
|
||||||
const heartbeatAgents = resolveHeartbeatAgents(cfg);
|
|
||||||
const intervals = heartbeatAgents
|
|
||||||
.map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat))
|
|
||||||
.filter((value): value is number => typeof value === "number");
|
|
||||||
const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null;
|
|
||||||
if (!intervalMs) {
|
|
||||||
log.info("heartbeat: disabled", { enabled: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
const lastRunByAgent = new Map<string, number>();
|
const runOnce = opts.runOnce ?? runHeartbeatOnce;
|
||||||
|
const state = {
|
||||||
|
cfg: opts.cfg ?? loadConfig(),
|
||||||
|
runtime,
|
||||||
|
agents: new Map<string, HeartbeatAgentState>(),
|
||||||
|
timer: null as NodeJS.Timeout | null,
|
||||||
|
stopped: false,
|
||||||
|
};
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
const resolveNextDue = (now: number, intervalMs: number, prevState?: HeartbeatAgentState) => {
|
||||||
|
if (typeof prevState?.lastRunMs === "number") {
|
||||||
|
return prevState.lastRunMs + intervalMs;
|
||||||
|
}
|
||||||
|
if (prevState && prevState.intervalMs === intervalMs && prevState.nextDueMs > now) {
|
||||||
|
return prevState.nextDueMs;
|
||||||
|
}
|
||||||
|
return now + intervalMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
if (state.stopped) return;
|
||||||
|
if (state.timer) {
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
}
|
||||||
|
if (state.agents.size === 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
let nextDue = Number.POSITIVE_INFINITY;
|
||||||
|
for (const agent of state.agents.values()) {
|
||||||
|
if (agent.nextDueMs < nextDue) nextDue = agent.nextDueMs;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(nextDue)) return;
|
||||||
|
const delay = Math.max(0, nextDue - now);
|
||||||
|
state.timer = setTimeout(() => {
|
||||||
|
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
||||||
|
}, delay);
|
||||||
|
state.timer.unref?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (cfg: ClawdbotConfig) => {
|
||||||
|
if (state.stopped) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const prevAgents = state.agents;
|
||||||
|
const prevEnabled = prevAgents.size > 0;
|
||||||
|
const nextAgents = new Map<string, HeartbeatAgentState>();
|
||||||
|
const intervals: number[] = [];
|
||||||
|
for (const agent of resolveHeartbeatAgents(cfg)) {
|
||||||
|
const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
|
||||||
|
if (!intervalMs) continue;
|
||||||
|
intervals.push(intervalMs);
|
||||||
|
const prevState = prevAgents.get(agent.agentId);
|
||||||
|
const nextDueMs = resolveNextDue(now, intervalMs, prevState);
|
||||||
|
nextAgents.set(agent.agentId, {
|
||||||
|
agentId: agent.agentId,
|
||||||
|
heartbeat: agent.heartbeat,
|
||||||
|
intervalMs,
|
||||||
|
lastRunMs: prevState?.lastRunMs,
|
||||||
|
nextDueMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.cfg = cfg;
|
||||||
|
state.agents = nextAgents;
|
||||||
|
const nextEnabled = nextAgents.size > 0;
|
||||||
|
if (!initialized) {
|
||||||
|
if (!nextEnabled) {
|
||||||
|
log.info("heartbeat: disabled", { enabled: false });
|
||||||
|
} else {
|
||||||
|
log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
} else if (prevEnabled !== nextEnabled) {
|
||||||
|
if (!nextEnabled) {
|
||||||
|
log.info("heartbeat: disabled", { enabled: false });
|
||||||
|
} else {
|
||||||
|
log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
|
};
|
||||||
|
|
||||||
const run: HeartbeatWakeHandler = async (params) => {
|
const run: HeartbeatWakeHandler = async (params) => {
|
||||||
if (!heartbeatsEnabled) {
|
if (!heartbeatsEnabled) {
|
||||||
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||||
}
|
}
|
||||||
if (heartbeatAgents.length === 0) {
|
if (state.agents.size === 0) {
|
||||||
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,52 +770,44 @@ export function startHeartbeatRunner(opts: {
|
|||||||
const now = startedAt;
|
const now = startedAt;
|
||||||
let ran = false;
|
let ran = false;
|
||||||
|
|
||||||
for (const agent of heartbeatAgents) {
|
for (const agent of state.agents.values()) {
|
||||||
const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
|
if (isInterval && now < agent.nextDueMs) {
|
||||||
if (!agentIntervalMs) continue;
|
|
||||||
const lastRun = lastRunByAgent.get(agent.agentId);
|
|
||||||
if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await runHeartbeatOnce({
|
const res = await runOnce({
|
||||||
cfg,
|
cfg: state.cfg,
|
||||||
agentId: agent.agentId,
|
agentId: agent.agentId,
|
||||||
heartbeat: agent.heartbeat,
|
heartbeat: agent.heartbeat,
|
||||||
reason,
|
reason,
|
||||||
deps: { runtime },
|
deps: { runtime: state.runtime },
|
||||||
});
|
});
|
||||||
if (res.status === "skipped" && res.reason === "requests-in-flight") {
|
if (res.status === "skipped" && res.reason === "requests-in-flight") {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
if (res.status !== "skipped" || res.reason !== "disabled") {
|
if (res.status !== "skipped" || res.reason !== "disabled") {
|
||||||
lastRunByAgent.set(agent.agentId, now);
|
agent.lastRunMs = now;
|
||||||
|
agent.nextDueMs = now + agent.intervalMs;
|
||||||
}
|
}
|
||||||
if (res.status === "ran") ran = true;
|
if (res.status === "ran") ran = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
|
if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
|
||||||
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
|
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
|
||||||
};
|
};
|
||||||
|
|
||||||
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
|
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
|
||||||
|
updateConfig(state.cfg);
|
||||||
let timer: NodeJS.Timeout | null = null;
|
|
||||||
if (intervalMs) {
|
|
||||||
timer = setInterval(() => {
|
|
||||||
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
|
||||||
}, intervalMs);
|
|
||||||
timer.unref?.();
|
|
||||||
log.info("heartbeat: started", { intervalMs });
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
state.stopped = true;
|
||||||
setHeartbeatWakeHandler(null);
|
setHeartbeatWakeHandler(null);
|
||||||
if (timer) clearInterval(timer);
|
if (state.timer) clearTimeout(state.timer);
|
||||||
timer = null;
|
state.timer = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
|
opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
|
||||||
|
|
||||||
return { stop: cleanup };
|
return { stop: cleanup, updateConfig };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user