refactor(src): split oversized modules
This commit is contained in:
BIN
src/cron/.DS_Store
vendored
Normal file
BIN
src/cron/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -474,463 +474,4 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips delivery without a WhatsApp recipient when bestEffortDeliver=true", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
bestEffortDeliver: true,
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("skipped");
|
||||
expect(String(res.summary ?? "")).toMatch(/delivery skipped/i);
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram via channel send", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram topic targets via channel send", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "-1001234567890",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "telegram:group:-1001234567890:topic:321",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"telegram:group:-1001234567890:topic:321",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram shorthand topic suffixes via channel send", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "-1001234567890",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "-1001234567890:321",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"-1001234567890:321",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers via discord when configured", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn().mockResolvedValue({
|
||||
messageId: "d1",
|
||||
channelId: "chan",
|
||||
}),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "discord",
|
||||
to: "channel:1122",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
|
||||
"channel:1122",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips delivery when response is exactly HEARTBEAT_OK", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
// Job still succeeds, but no delivery happens.
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.summary).toBe("HEARTBEAT_OK");
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips delivery when response has HEARTBEAT_OK with short padding", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn().mockResolvedValue({
|
||||
messageId: "w1",
|
||||
chatId: "+1234",
|
||||
}),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Short junk around HEARTBEAT_OK (<=30 chars) should still skip delivery.
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { whatsapp: { allowFrom: ["+1234"] } },
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+1234",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when response has HEARTBEAT_OK but also substantial content", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Long content after HEARTBEAT_OK should still be delivered.
|
||||
const longContent = `Important alert: ${"a".repeat(500)}`;
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: `HEARTBEAT_OK ${longContent}` }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when response has HEARTBEAT_OK but includes media", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Media should still be delivered even if text is just HEARTBEAT_OK.
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [
|
||||
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
|
||||
],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"HEARTBEAT_OK",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/img.png" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when heartbeat ack padding exceeds configured limit", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = makeCfg(home, storePath);
|
||||
cfg.agents = {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
heartbeat: { ackMaxChars: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg,
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
451
src/cron/isolated-agent.part-2.test.ts
Normal file
451
src/cron/isolated-agent.part-2.test.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-cron-" });
|
||||
}
|
||||
|
||||
async function writeSessionStore(home: string) {
|
||||
const dir = path.join(home, ".clawdbot", "sessions");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "main-session",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "webchat",
|
||||
lastTo: "",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
return storePath;
|
||||
}
|
||||
|
||||
function makeCfg(
|
||||
home: string,
|
||||
storePath: string,
|
||||
overrides: Partial<ClawdbotConfig> = {},
|
||||
): ClawdbotConfig {
|
||||
const base: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
session: { store: storePath, mainKey: "main" },
|
||||
} as ClawdbotConfig;
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function makeJob(payload: CronJob["payload"]): CronJob {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: "job-1",
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload,
|
||||
state: {},
|
||||
isolation: { postToMainPrefix: "Cron" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("skips delivery without a WhatsApp recipient when bestEffortDeliver=true", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
bestEffortDeliver: true,
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("skipped");
|
||||
expect(String(res.summary ?? "")).toMatch(/delivery skipped/i);
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram via channel send", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
process.env.TELEGRAM_BOT_TOKEN = "";
|
||||
try {
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { telegram: { botToken: "t-1" } },
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
if (prevTelegramToken === undefined) {
|
||||
delete process.env.TELEGRAM_BOT_TOKEN;
|
||||
} else {
|
||||
process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram topic targets via channel send", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "-1001234567890",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "telegram:group:-1001234567890:topic:321",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"telegram:group:-1001234567890:topic:321",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers telegram shorthand topic suffixes via channel send", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "-1001234567890",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "-1001234567890:321",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"-1001234567890:321",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers via discord when configured", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn().mockResolvedValue({
|
||||
messageId: "d1",
|
||||
channelId: "chan",
|
||||
}),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "hello from cron" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "discord",
|
||||
to: "channel:1122",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
|
||||
"channel:1122",
|
||||
"hello from cron",
|
||||
expect.objectContaining({ verbose: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips delivery when response is exactly HEARTBEAT_OK", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
// Job still succeeds, but no delivery happens.
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.summary).toBe("HEARTBEAT_OK");
|
||||
expect(deps.sendMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips delivery when response has HEARTBEAT_OK with short padding", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn().mockResolvedValue({
|
||||
messageId: "w1",
|
||||
chatId: "+1234",
|
||||
}),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Short junk around HEARTBEAT_OK (<=30 chars) should still skip delivery.
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath, {
|
||||
channels: { whatsapp: { allowFrom: ["+1234"] } },
|
||||
}),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "whatsapp",
|
||||
to: "+1234",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when response has HEARTBEAT_OK but also substantial content", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Long content after HEARTBEAT_OK should still be delivered.
|
||||
const longContent = `Important alert: ${"a".repeat(500)}`;
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: `HEARTBEAT_OK ${longContent}` }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
188
src/cron/isolated-agent.part-3.test.ts
Normal file
188
src/cron/isolated-agent.part-3.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "clawdbot-cron-" });
|
||||
}
|
||||
|
||||
async function writeSessionStore(home: string) {
|
||||
const dir = path.join(home, ".clawdbot", "sessions");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "main-session",
|
||||
updatedAt: Date.now(),
|
||||
lastProvider: "webchat",
|
||||
lastTo: "",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
return storePath;
|
||||
}
|
||||
|
||||
function makeCfg(
|
||||
home: string,
|
||||
storePath: string,
|
||||
overrides: Partial<ClawdbotConfig> = {},
|
||||
): ClawdbotConfig {
|
||||
const base: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
session: { store: storePath, mainKey: "main" },
|
||||
} as ClawdbotConfig;
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function makeJob(payload: CronJob["payload"]): CronJob {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: "job-1",
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload,
|
||||
state: {},
|
||||
isolation: { postToMainPrefix: "Cron" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("delivers when response has HEARTBEAT_OK but includes media", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
// Media should still be delivered even if text is just HEARTBEAT_OK.
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [
|
||||
{ text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" },
|
||||
],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"HEARTBEAT_OK",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/img.png" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when heartbeat ack padding exceeds configured limit", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn().mockResolvedValue({
|
||||
messageId: "t1",
|
||||
chatId: "123",
|
||||
}),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "HEARTBEAT_OK 🦞" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = makeCfg(home, storePath);
|
||||
cfg.agents = {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
heartbeat: { ackMaxChars: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runCronIsolatedAgentTurn({
|
||||
cfg,
|
||||
deps,
|
||||
job: makeJob({
|
||||
kind: "agentTurn",
|
||||
message: "do it",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
}),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
expect(res.status).toBe("ok");
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,610 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentModelFallbacksOverride,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { runCliAgent } from "../agents/cli-runner.js";
|
||||
import { getCliSessionId, setCliSessionId } from "../agents/cli-session.js";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../agents/defaults.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runWithModelFallback } from "../agents/model-fallback.js";
|
||||
import {
|
||||
getModelRefStatus,
|
||||
isCliProvider,
|
||||
resolveAllowedModelRef,
|
||||
resolveConfiguredModelRef,
|
||||
resolveHooksGmailModel,
|
||||
resolveThinkingDefault,
|
||||
} from "../agents/model-selection.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||
import { hasNonzeroUsage } from "../agents/usage.js";
|
||||
import { ensureAgentWorkspace } from "../agents/workspace.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
stripHeartbeatToken,
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
import {
|
||||
formatXHighModelHint,
|
||||
normalizeThinkLevel,
|
||||
supportsXHighThinking,
|
||||
} from "../auto-reply/thinking.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
loadSessionStore,
|
||||
resolveAgentMainSessionKey,
|
||||
resolveSessionTranscriptPath,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import type { AgentDefaultsConfig } from "../config/types.js";
|
||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
|
||||
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||
import type { OutboundChannel } from "../infra/outbound/targets.js";
|
||||
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
normalizeAgentId,
|
||||
} from "../routing/session-key.js";
|
||||
import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
normalizeMessageChannel,
|
||||
} from "../utils/message-channel.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
export type RunCronAgentTurnResult = {
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type DeliveryPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
|
||||
function pickSummaryFromOutput(text: string | undefined) {
|
||||
const clean = (text ?? "").trim();
|
||||
if (!clean) return undefined;
|
||||
const limit = 2000;
|
||||
return clean.length > limit ? `${truncateUtf16Safe(clean, limit)}…` : clean;
|
||||
}
|
||||
|
||||
function pickSummaryFromPayloads(
|
||||
payloads: Array<{ text?: string | undefined }>,
|
||||
) {
|
||||
for (let i = payloads.length - 1; i >= 0; i--) {
|
||||
const summary = pickSummaryFromOutput(payloads[i]?.text);
|
||||
if (summary) return summary;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK).
|
||||
* Returns true if delivery should be skipped because there's no real content.
|
||||
*/
|
||||
function isHeartbeatOnlyResponse(
|
||||
payloads: DeliveryPayload[],
|
||||
ackMaxChars: number,
|
||||
) {
|
||||
if (payloads.length === 0) return true;
|
||||
return payloads.every((payload) => {
|
||||
// If there's media, we should deliver regardless of text content.
|
||||
const hasMedia =
|
||||
(payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl);
|
||||
if (hasMedia) return false;
|
||||
// Use heartbeat mode to check if text is just HEARTBEAT_OK or short ack.
|
||||
const result = stripHeartbeatToken(payload.text, {
|
||||
mode: "heartbeat",
|
||||
maxAckChars: ackMaxChars,
|
||||
});
|
||||
return result.shouldSkip;
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveDeliveryTarget(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
jobPayload: {
|
||||
channel?: "last" | ChannelId;
|
||||
to?: string;
|
||||
},
|
||||
): Promise<{
|
||||
channel: Exclude<OutboundChannel, "none">;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
mode: "explicit" | "implicit";
|
||||
error?: Error;
|
||||
}> {
|
||||
const requestedRaw =
|
||||
typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
|
||||
const requestedChannelHint =
|
||||
normalizeMessageChannel(requestedRaw) ?? requestedRaw;
|
||||
const explicitTo =
|
||||
typeof jobPayload.to === "string" && jobPayload.to.trim()
|
||||
? jobPayload.to.trim()
|
||||
: undefined;
|
||||
|
||||
const sessionCfg = cfg.session;
|
||||
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const main = store[mainSessionKey];
|
||||
const lastChannel =
|
||||
main?.lastChannel && main.lastChannel !== INTERNAL_MESSAGE_CHANNEL
|
||||
? normalizeChannelId(main.lastChannel)
|
||||
: undefined;
|
||||
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
||||
const lastAccountId = main?.lastAccountId;
|
||||
|
||||
let channel: Exclude<OutboundChannel, "none"> | undefined =
|
||||
requestedChannelHint === "last"
|
||||
? (lastChannel ?? undefined)
|
||||
: requestedChannelHint === INTERNAL_MESSAGE_CHANNEL
|
||||
? undefined
|
||||
: (normalizeChannelId(requestedChannelHint) ?? undefined);
|
||||
if (!channel) {
|
||||
try {
|
||||
const selection = await resolveMessageChannelSelection({ cfg });
|
||||
channel = selection.channel;
|
||||
} catch {
|
||||
channel = lastChannel ?? DEFAULT_CHAT_CHANNEL;
|
||||
}
|
||||
}
|
||||
|
||||
const toCandidate = explicitTo ?? (lastTo || undefined);
|
||||
const mode: "explicit" | "implicit" = explicitTo ? "explicit" : "implicit";
|
||||
if (!toCandidate) {
|
||||
return { channel, to: undefined, accountId: lastAccountId, mode };
|
||||
}
|
||||
|
||||
const resolved = resolveOutboundTarget({
|
||||
channel,
|
||||
to: toCandidate,
|
||||
cfg,
|
||||
accountId: channel === lastChannel ? lastAccountId : undefined,
|
||||
mode,
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
to: resolved.ok ? resolved.to : undefined,
|
||||
accountId: channel === lastChannel ? lastAccountId : undefined,
|
||||
mode,
|
||||
error: resolved.ok ? undefined : resolved.error,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCronSession(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
sessionKey: string;
|
||||
nowMs: number;
|
||||
agentId: string;
|
||||
}) {
|
||||
const sessionCfg = params.cfg.session;
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const fresh = entry && params.nowMs - entry.updatedAt <= idleMs;
|
||||
const sessionId = fresh ? entry.sessionId : crypto.randomUUID();
|
||||
const systemSent = fresh ? Boolean(entry.systemSent) : false;
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: params.nowMs,
|
||||
systemSent,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh };
|
||||
}
|
||||
|
||||
export async function runCronIsolatedAgentTurn(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
deps: CliDeps;
|
||||
job: CronJob;
|
||||
message: string;
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
lane?: string;
|
||||
}): Promise<RunCronAgentTurnResult> {
|
||||
const defaultAgentId = resolveDefaultAgentId(params.cfg);
|
||||
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 = Object.assign(
|
||||
{},
|
||||
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: Object.assign({}, 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({
|
||||
dir: workspaceDirRaw,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
let provider = resolvedDefault.provider;
|
||||
let model = resolvedDefault.model;
|
||||
let catalog: Awaited<ReturnType<typeof loadModelCatalog>> | undefined;
|
||||
const loadCatalog = async () => {
|
||||
if (!catalog) {
|
||||
catalog = await loadModelCatalog({ config: cfgWithAgentDefaults });
|
||||
}
|
||||
return catalog;
|
||||
};
|
||||
// Resolve model - prefer hooks.gmail.model for Gmail hooks.
|
||||
const isGmailHook = baseSessionKey.startsWith("hook:gmail:");
|
||||
const hooksGmailModelRef = isGmailHook
|
||||
? resolveHooksGmailModel({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
})
|
||||
: null;
|
||||
if (hooksGmailModelRef) {
|
||||
const status = getModelRefStatus({
|
||||
cfg: params.cfg,
|
||||
catalog: await loadCatalog(),
|
||||
ref: hooksGmailModelRef,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
if (status.allowed) {
|
||||
provider = hooksGmailModelRef.provider;
|
||||
model = hooksGmailModelRef.model;
|
||||
}
|
||||
}
|
||||
const modelOverrideRaw =
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.model
|
||||
: undefined;
|
||||
if (modelOverrideRaw !== undefined) {
|
||||
if (typeof modelOverrideRaw !== "string") {
|
||||
return { status: "error", error: "invalid model: expected string" };
|
||||
}
|
||||
const resolvedOverride = resolveAllowedModelRef({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
catalog: await loadCatalog(),
|
||||
raw: modelOverrideRaw,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
if ("error" in resolvedOverride) {
|
||||
return { status: "error", error: resolvedOverride.error };
|
||||
}
|
||||
provider = resolvedOverride.ref.provider;
|
||||
model = resolvedOverride.ref.model;
|
||||
}
|
||||
const now = Date.now();
|
||||
const cronSession = resolveCronSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: agentSessionKey,
|
||||
agentId,
|
||||
nowMs: now,
|
||||
});
|
||||
const isFirstTurnInSession =
|
||||
cronSession.isNewSession || !cronSession.systemSent;
|
||||
|
||||
// Resolve thinking level - job thinking > hooks.gmail.thinking > agent default
|
||||
const hooksGmailThinking = isGmailHook
|
||||
? normalizeThinkLevel(params.cfg.hooks?.gmail?.thinking)
|
||||
: undefined;
|
||||
const thinkOverride = normalizeThinkLevel(agentCfg?.thinkingDefault);
|
||||
const jobThink = normalizeThinkLevel(
|
||||
(params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.thinking
|
||||
: undefined) ?? undefined,
|
||||
);
|
||||
let thinkLevel = jobThink ?? hooksGmailThinking ?? thinkOverride;
|
||||
if (!thinkLevel) {
|
||||
thinkLevel = resolveThinkingDefault({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
provider,
|
||||
model,
|
||||
catalog: await loadCatalog(),
|
||||
});
|
||||
}
|
||||
if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
|
||||
throw new Error(
|
||||
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const timeoutMs = resolveAgentTimeoutMs({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
overrideSeconds:
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.timeoutSeconds
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const delivery =
|
||||
params.job.payload.kind === "agentTurn" &&
|
||||
params.job.payload.deliver === true;
|
||||
const bestEffortDeliver =
|
||||
params.job.payload.kind === "agentTurn" &&
|
||||
params.job.payload.bestEffortDeliver === true;
|
||||
|
||||
const resolvedDelivery = await resolveDeliveryTarget(
|
||||
cfgWithAgentDefaults,
|
||||
agentId,
|
||||
{
|
||||
channel:
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? (params.job.payload.channel ?? "last")
|
||||
: "last",
|
||||
to:
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.to
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const base =
|
||||
`[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
|
||||
|
||||
const commandBody = base;
|
||||
|
||||
const needsSkillsSnapshot =
|
||||
cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
|
||||
const skillsSnapshot = needsSkillsSnapshot
|
||||
? buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||
config: cfgWithAgentDefaults,
|
||||
})
|
||||
: cronSession.sessionEntry.skillsSnapshot;
|
||||
if (needsSkillsSnapshot && skillsSnapshot) {
|
||||
cronSession.sessionEntry = {
|
||||
...cronSession.sessionEntry,
|
||||
updatedAt: Date.now(),
|
||||
skillsSnapshot,
|
||||
};
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
}
|
||||
|
||||
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
|
||||
if (isFirstTurnInSession) {
|
||||
cronSession.sessionEntry.systemSent = true;
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
} else {
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
}
|
||||
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = provider;
|
||||
let fallbackModel = model;
|
||||
try {
|
||||
const sessionFile = resolveSessionTranscriptPath(
|
||||
cronSession.sessionEntry.sessionId,
|
||||
agentId,
|
||||
);
|
||||
const resolvedVerboseLevel =
|
||||
(cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ??
|
||||
(agentCfg?.verboseDefault as "on" | "off" | undefined);
|
||||
registerAgentRunContext(cronSession.sessionEntry.sessionId, {
|
||||
sessionKey: agentSessionKey,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
});
|
||||
const messageChannel = resolvedDelivery.channel;
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
provider,
|
||||
model,
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
params.cfg,
|
||||
agentId,
|
||||
),
|
||||
run: (providerOverride, modelOverride) => {
|
||||
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
|
||||
const cliSessionId = getCliSessionId(
|
||||
cronSession.sessionEntry,
|
||||
providerOverride,
|
||||
);
|
||||
return runCliAgent({
|
||||
sessionId: cronSession.sessionEntry.sessionId,
|
||||
sessionKey: agentSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfgWithAgentDefaults,
|
||||
prompt: commandBody,
|
||||
provider: providerOverride,
|
||||
model: modelOverride,
|
||||
thinkLevel,
|
||||
timeoutMs,
|
||||
runId: cronSession.sessionEntry.sessionId,
|
||||
cliSessionId,
|
||||
});
|
||||
}
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: cronSession.sessionEntry.sessionId,
|
||||
sessionKey: agentSessionKey,
|
||||
messageChannel,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfgWithAgentDefaults,
|
||||
skillsSnapshot,
|
||||
prompt: commandBody,
|
||||
lane: params.lane ?? "cron",
|
||||
provider: providerOverride,
|
||||
model: modelOverride,
|
||||
thinkLevel,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
timeoutMs,
|
||||
runId: cronSession.sessionEntry.sessionId,
|
||||
});
|
||||
},
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
} catch (err) {
|
||||
return { status: "error", error: String(err) };
|
||||
}
|
||||
|
||||
const payloads = runResult.payloads ?? [];
|
||||
|
||||
// Update token+model fields in the session store.
|
||||
{
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? model;
|
||||
const providerUsed =
|
||||
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? provider;
|
||||
const contextTokens =
|
||||
agentCfg?.contextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
cronSession.sessionEntry.modelProvider = providerUsed;
|
||||
cronSession.sessionEntry.model = modelUsed;
|
||||
cronSession.sessionEntry.contextTokens = contextTokens;
|
||||
if (isCliProvider(providerUsed, cfgWithAgentDefaults)) {
|
||||
const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim();
|
||||
if (cliSessionId) {
|
||||
setCliSessionId(cronSession.sessionEntry, providerUsed, cliSessionId);
|
||||
}
|
||||
}
|
||||
if (hasNonzeroUsage(usage)) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
cronSession.sessionEntry.inputTokens = input;
|
||||
cronSession.sessionEntry.outputTokens = output;
|
||||
cronSession.sessionEntry.totalTokens =
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input);
|
||||
}
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
}
|
||||
const firstText = payloads[0]?.text ?? "";
|
||||
const summary =
|
||||
pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
|
||||
|
||||
// Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content).
|
||||
// This allows cron jobs to silently ack when nothing to report but still deliver
|
||||
// actual content when there is something to say.
|
||||
const ackMaxChars =
|
||||
agentCfg?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
|
||||
const skipHeartbeatDelivery =
|
||||
delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars));
|
||||
|
||||
if (delivery && !skipHeartbeatDelivery) {
|
||||
if (!resolvedDelivery.to) {
|
||||
const reason =
|
||||
resolvedDelivery.error?.message ??
|
||||
"Cron delivery requires a recipient (--to).";
|
||||
if (!bestEffortDeliver) {
|
||||
return {
|
||||
status: "error",
|
||||
summary,
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
summary: `Delivery skipped (${reason}).`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await deliverOutboundPayloads({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
channel: resolvedDelivery.channel,
|
||||
to: resolvedDelivery.to,
|
||||
accountId: resolvedDelivery.accountId,
|
||||
payloads,
|
||||
bestEffort: bestEffortDeliver,
|
||||
deps: {
|
||||
sendWhatsApp: params.deps.sendMessageWhatsApp,
|
||||
sendTelegram: params.deps.sendMessageTelegram,
|
||||
sendDiscord: params.deps.sendMessageDiscord,
|
||||
sendSlack: params.deps.sendMessageSlack,
|
||||
sendSignal: params.deps.sendMessageSignal,
|
||||
sendIMessage: params.deps.sendMessageIMessage,
|
||||
sendMSTeams: params.deps.sendMessageMSTeams
|
||||
? async (to, text, opts) =>
|
||||
await params.deps.sendMessageMSTeams({
|
||||
cfg: params.cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl: opts?.mediaUrl,
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver) {
|
||||
return { status: "error", summary, error: String(err) };
|
||||
}
|
||||
return { status: "ok", summary };
|
||||
}
|
||||
}
|
||||
|
||||
return { status: "ok", summary };
|
||||
}
|
||||
export {
|
||||
type RunCronAgentTurnResult,
|
||||
runCronIsolatedAgentTurn,
|
||||
} from "./isolated-agent/run.js";
|
||||
|
||||
88
src/cron/isolated-agent/delivery-target.ts
Normal file
88
src/cron/isolated-agent/delivery-target.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveAgentMainSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../../config/sessions.js";
|
||||
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
|
||||
import type { OutboundChannel } from "../../infra/outbound/targets.js";
|
||||
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
||||
import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
normalizeMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
|
||||
export async function resolveDeliveryTarget(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
jobPayload: {
|
||||
channel?: "last" | ChannelId;
|
||||
to?: string;
|
||||
},
|
||||
): Promise<{
|
||||
channel: Exclude<OutboundChannel, "none">;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
mode: "explicit" | "implicit";
|
||||
error?: Error;
|
||||
}> {
|
||||
const requestedRaw =
|
||||
typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
|
||||
const requestedChannelHint =
|
||||
normalizeMessageChannel(requestedRaw) ?? requestedRaw;
|
||||
const explicitTo =
|
||||
typeof jobPayload.to === "string" && jobPayload.to.trim()
|
||||
? jobPayload.to.trim()
|
||||
: undefined;
|
||||
|
||||
const sessionCfg = cfg.session;
|
||||
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const main = store[mainSessionKey];
|
||||
const lastChannel =
|
||||
main?.lastChannel && main.lastChannel !== INTERNAL_MESSAGE_CHANNEL
|
||||
? normalizeChannelId(main.lastChannel)
|
||||
: undefined;
|
||||
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
||||
const lastAccountId = main?.lastAccountId;
|
||||
|
||||
let channel: Exclude<OutboundChannel, "none"> | undefined =
|
||||
requestedChannelHint === "last"
|
||||
? (lastChannel ?? undefined)
|
||||
: requestedChannelHint === INTERNAL_MESSAGE_CHANNEL
|
||||
? undefined
|
||||
: (normalizeChannelId(requestedChannelHint) ?? undefined);
|
||||
if (!channel) {
|
||||
try {
|
||||
const selection = await resolveMessageChannelSelection({ cfg });
|
||||
channel = selection.channel;
|
||||
} catch {
|
||||
channel = lastChannel ?? DEFAULT_CHAT_CHANNEL;
|
||||
}
|
||||
}
|
||||
|
||||
const toCandidate = explicitTo ?? (lastTo || undefined);
|
||||
const mode: "explicit" | "implicit" = explicitTo ? "explicit" : "implicit";
|
||||
if (!toCandidate) {
|
||||
return { channel, to: undefined, accountId: lastAccountId, mode };
|
||||
}
|
||||
|
||||
const resolved = resolveOutboundTarget({
|
||||
channel,
|
||||
to: toCandidate,
|
||||
cfg,
|
||||
accountId: channel === lastChannel ? lastAccountId : undefined,
|
||||
mode,
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
to: resolved.ok ? resolved.to : undefined,
|
||||
accountId: channel === lastChannel ? lastAccountId : undefined,
|
||||
mode,
|
||||
error: resolved.ok ? undefined : resolved.error,
|
||||
};
|
||||
}
|
||||
59
src/cron/isolated-agent/helpers.ts
Normal file
59
src/cron/isolated-agent/helpers.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
stripHeartbeatToken,
|
||||
} from "../../auto-reply/heartbeat.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
|
||||
type DeliveryPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
|
||||
export function pickSummaryFromOutput(text: string | undefined) {
|
||||
const clean = (text ?? "").trim();
|
||||
if (!clean) return undefined;
|
||||
const limit = 2000;
|
||||
return clean.length > limit ? `${truncateUtf16Safe(clean, limit)}…` : clean;
|
||||
}
|
||||
|
||||
export function pickSummaryFromPayloads(
|
||||
payloads: Array<{ text?: string | undefined }>,
|
||||
) {
|
||||
for (let i = payloads.length - 1; i >= 0; i--) {
|
||||
const summary = pickSummaryFromOutput(payloads[i]?.text);
|
||||
if (summary) return summary;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK).
|
||||
* Returns true if delivery should be skipped because there's no real content.
|
||||
*/
|
||||
export function isHeartbeatOnlyResponse(
|
||||
payloads: DeliveryPayload[],
|
||||
ackMaxChars: number,
|
||||
) {
|
||||
if (payloads.length === 0) return true;
|
||||
return payloads.every((payload) => {
|
||||
// If there's media, we should deliver regardless of text content.
|
||||
const hasMedia =
|
||||
(payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl);
|
||||
if (hasMedia) return false;
|
||||
// Use heartbeat mode to check if text is just HEARTBEAT_OK or short ack.
|
||||
const result = stripHeartbeatToken(payload.text, {
|
||||
mode: "heartbeat",
|
||||
maxAckChars: ackMaxChars,
|
||||
});
|
||||
return result.shouldSkip;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveHeartbeatAckMaxChars(agentCfg?: {
|
||||
heartbeat?: { ackMaxChars?: number };
|
||||
}) {
|
||||
const raw =
|
||||
agentCfg?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS;
|
||||
return Math.max(0, raw);
|
||||
}
|
||||
433
src/cron/isolated-agent/run.ts
Normal file
433
src/cron/isolated-agent/run.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentModelFallbacksOverride,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { runCliAgent } from "../../agents/cli-runner.js";
|
||||
import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../../agents/defaults.js";
|
||||
import { loadModelCatalog } from "../../agents/model-catalog.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import {
|
||||
getModelRefStatus,
|
||||
isCliProvider,
|
||||
resolveAllowedModelRef,
|
||||
resolveConfiguredModelRef,
|
||||
resolveHooksGmailModel,
|
||||
resolveThinkingDefault,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import { ensureAgentWorkspace } from "../../agents/workspace.js";
|
||||
import {
|
||||
formatXHighModelHint,
|
||||
normalizeThinkLevel,
|
||||
supportsXHighThinking,
|
||||
} from "../../auto-reply/thinking.js";
|
||||
import type { CliDeps } from "../../cli/deps.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveSessionTranscriptPath,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import type { AgentDefaultsConfig } from "../../config/types.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
normalizeAgentId,
|
||||
} from "../../routing/session-key.js";
|
||||
import type { CronJob } from "../types.js";
|
||||
import { resolveDeliveryTarget } from "./delivery-target.js";
|
||||
import {
|
||||
isHeartbeatOnlyResponse,
|
||||
pickSummaryFromOutput,
|
||||
pickSummaryFromPayloads,
|
||||
resolveHeartbeatAckMaxChars,
|
||||
} from "./helpers.js";
|
||||
import { resolveCronSession } from "./session.js";
|
||||
|
||||
export type RunCronAgentTurnResult = {
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function runCronIsolatedAgentTurn(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
deps: CliDeps;
|
||||
job: CronJob;
|
||||
message: string;
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
lane?: string;
|
||||
}): Promise<RunCronAgentTurnResult> {
|
||||
const defaultAgentId = resolveDefaultAgentId(params.cfg);
|
||||
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 = Object.assign(
|
||||
{},
|
||||
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: Object.assign({}, 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({
|
||||
dir: workspaceDirRaw,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
let provider = resolvedDefault.provider;
|
||||
let model = resolvedDefault.model;
|
||||
let catalog: Awaited<ReturnType<typeof loadModelCatalog>> | undefined;
|
||||
const loadCatalog = async () => {
|
||||
if (!catalog) {
|
||||
catalog = await loadModelCatalog({ config: cfgWithAgentDefaults });
|
||||
}
|
||||
return catalog;
|
||||
};
|
||||
// Resolve model - prefer hooks.gmail.model for Gmail hooks.
|
||||
const isGmailHook = baseSessionKey.startsWith("hook:gmail:");
|
||||
const hooksGmailModelRef = isGmailHook
|
||||
? resolveHooksGmailModel({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
})
|
||||
: null;
|
||||
if (hooksGmailModelRef) {
|
||||
const status = getModelRefStatus({
|
||||
cfg: params.cfg,
|
||||
catalog: await loadCatalog(),
|
||||
ref: hooksGmailModelRef,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
if (status.allowed) {
|
||||
provider = hooksGmailModelRef.provider;
|
||||
model = hooksGmailModelRef.model;
|
||||
}
|
||||
}
|
||||
const modelOverrideRaw =
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.model
|
||||
: undefined;
|
||||
if (modelOverrideRaw !== undefined) {
|
||||
if (typeof modelOverrideRaw !== "string") {
|
||||
return { status: "error", error: "invalid model: expected string" };
|
||||
}
|
||||
const resolvedOverride = resolveAllowedModelRef({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
catalog: await loadCatalog(),
|
||||
raw: modelOverrideRaw,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
if ("error" in resolvedOverride) {
|
||||
return { status: "error", error: resolvedOverride.error };
|
||||
}
|
||||
provider = resolvedOverride.ref.provider;
|
||||
model = resolvedOverride.ref.model;
|
||||
}
|
||||
const now = Date.now();
|
||||
const cronSession = resolveCronSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: agentSessionKey,
|
||||
agentId,
|
||||
nowMs: now,
|
||||
});
|
||||
|
||||
// Resolve thinking level - job thinking > hooks.gmail.thinking > agent default
|
||||
const hooksGmailThinking = isGmailHook
|
||||
? normalizeThinkLevel(params.cfg.hooks?.gmail?.thinking)
|
||||
: undefined;
|
||||
const thinkOverride = normalizeThinkLevel(agentCfg?.thinkingDefault);
|
||||
const jobThink = normalizeThinkLevel(
|
||||
(params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.thinking
|
||||
: undefined) ?? undefined,
|
||||
);
|
||||
let thinkLevel = jobThink ?? hooksGmailThinking ?? thinkOverride;
|
||||
if (!thinkLevel) {
|
||||
thinkLevel = resolveThinkingDefault({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
provider,
|
||||
model,
|
||||
catalog: await loadCatalog(),
|
||||
});
|
||||
}
|
||||
if (thinkLevel === "xhigh" && !supportsXHighThinking(provider, model)) {
|
||||
throw new Error(
|
||||
`Thinking level "xhigh" is only supported for ${formatXHighModelHint()}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const timeoutMs = resolveAgentTimeoutMs({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
overrideSeconds:
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.timeoutSeconds
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const delivery =
|
||||
params.job.payload.kind === "agentTurn" &&
|
||||
params.job.payload.deliver === true;
|
||||
const bestEffortDeliver =
|
||||
params.job.payload.kind === "agentTurn" &&
|
||||
params.job.payload.bestEffortDeliver === true;
|
||||
|
||||
const resolvedDelivery = await resolveDeliveryTarget(
|
||||
cfgWithAgentDefaults,
|
||||
agentId,
|
||||
{
|
||||
channel:
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? (params.job.payload.channel ?? "last")
|
||||
: "last",
|
||||
to:
|
||||
params.job.payload.kind === "agentTurn"
|
||||
? params.job.payload.to
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const base =
|
||||
`[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
|
||||
const commandBody = base;
|
||||
|
||||
const needsSkillsSnapshot =
|
||||
cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
|
||||
const skillsSnapshot = needsSkillsSnapshot
|
||||
? buildWorkspaceSkillSnapshot(workspaceDir, {
|
||||
config: cfgWithAgentDefaults,
|
||||
})
|
||||
: cronSession.sessionEntry.skillsSnapshot;
|
||||
if (needsSkillsSnapshot && skillsSnapshot) {
|
||||
cronSession.sessionEntry = {
|
||||
...cronSession.sessionEntry,
|
||||
updatedAt: Date.now(),
|
||||
skillsSnapshot,
|
||||
};
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
}
|
||||
|
||||
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
|
||||
cronSession.sessionEntry.systemSent = true;
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = provider;
|
||||
let fallbackModel = model;
|
||||
try {
|
||||
const sessionFile = resolveSessionTranscriptPath(
|
||||
cronSession.sessionEntry.sessionId,
|
||||
agentId,
|
||||
);
|
||||
const resolvedVerboseLevel =
|
||||
(cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ??
|
||||
(agentCfg?.verboseDefault as "on" | "off" | undefined);
|
||||
registerAgentRunContext(cronSession.sessionEntry.sessionId, {
|
||||
sessionKey: agentSessionKey,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
});
|
||||
const messageChannel = resolvedDelivery.channel;
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
provider,
|
||||
model,
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
params.cfg,
|
||||
agentId,
|
||||
),
|
||||
run: (providerOverride, modelOverride) => {
|
||||
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
|
||||
const cliSessionId = getCliSessionId(
|
||||
cronSession.sessionEntry,
|
||||
providerOverride,
|
||||
);
|
||||
return runCliAgent({
|
||||
sessionId: cronSession.sessionEntry.sessionId,
|
||||
sessionKey: agentSessionKey,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfgWithAgentDefaults,
|
||||
prompt: commandBody,
|
||||
provider: providerOverride,
|
||||
model: modelOverride,
|
||||
thinkLevel,
|
||||
timeoutMs,
|
||||
runId: cronSession.sessionEntry.sessionId,
|
||||
cliSessionId,
|
||||
});
|
||||
}
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: cronSession.sessionEntry.sessionId,
|
||||
sessionKey: agentSessionKey,
|
||||
messageChannel,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfgWithAgentDefaults,
|
||||
skillsSnapshot,
|
||||
prompt: commandBody,
|
||||
lane: params.lane ?? "cron",
|
||||
provider: providerOverride,
|
||||
model: modelOverride,
|
||||
thinkLevel,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
timeoutMs,
|
||||
runId: cronSession.sessionEntry.sessionId,
|
||||
});
|
||||
},
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
} catch (err) {
|
||||
return { status: "error", error: String(err) };
|
||||
}
|
||||
|
||||
const payloads = runResult.payloads ?? [];
|
||||
|
||||
// Update token+model fields in the session store.
|
||||
{
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? model;
|
||||
const providerUsed =
|
||||
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? provider;
|
||||
const contextTokens =
|
||||
agentCfg?.contextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
cronSession.sessionEntry.modelProvider = providerUsed;
|
||||
cronSession.sessionEntry.model = modelUsed;
|
||||
cronSession.sessionEntry.contextTokens = contextTokens;
|
||||
if (isCliProvider(providerUsed, cfgWithAgentDefaults)) {
|
||||
const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim();
|
||||
if (cliSessionId) {
|
||||
setCliSessionId(cronSession.sessionEntry, providerUsed, cliSessionId);
|
||||
}
|
||||
}
|
||||
if (hasNonzeroUsage(usage)) {
|
||||
const input = usage.input ?? 0;
|
||||
const output = usage.output ?? 0;
|
||||
const promptTokens =
|
||||
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
||||
cronSession.sessionEntry.inputTokens = input;
|
||||
cronSession.sessionEntry.outputTokens = output;
|
||||
cronSession.sessionEntry.totalTokens =
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input);
|
||||
}
|
||||
cronSession.store[agentSessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
}
|
||||
const firstText = payloads[0]?.text ?? "";
|
||||
const summary =
|
||||
pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
|
||||
|
||||
// Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content).
|
||||
const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg);
|
||||
const skipHeartbeatDelivery =
|
||||
delivery && isHeartbeatOnlyResponse(payloads, ackMaxChars);
|
||||
|
||||
if (delivery && !skipHeartbeatDelivery) {
|
||||
if (!resolvedDelivery.to) {
|
||||
const reason =
|
||||
resolvedDelivery.error?.message ??
|
||||
"Cron delivery requires a recipient (--to).";
|
||||
if (!bestEffortDeliver) {
|
||||
return {
|
||||
status: "error",
|
||||
summary,
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
summary: `Delivery skipped (${reason}).`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await deliverOutboundPayloads({
|
||||
cfg: cfgWithAgentDefaults,
|
||||
channel: resolvedDelivery.channel,
|
||||
to: resolvedDelivery.to,
|
||||
accountId: resolvedDelivery.accountId,
|
||||
payloads,
|
||||
bestEffort: bestEffortDeliver,
|
||||
deps: {
|
||||
sendWhatsApp: params.deps.sendMessageWhatsApp,
|
||||
sendTelegram: params.deps.sendMessageTelegram,
|
||||
sendDiscord: params.deps.sendMessageDiscord,
|
||||
sendSlack: params.deps.sendMessageSlack,
|
||||
sendSignal: params.deps.sendMessageSignal,
|
||||
sendIMessage: params.deps.sendMessageIMessage,
|
||||
sendMSTeams: params.deps.sendMessageMSTeams
|
||||
? async (to, text, opts) =>
|
||||
await params.deps.sendMessageMSTeams({
|
||||
cfg: params.cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl: opts?.mediaUrl,
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver) {
|
||||
return { status: "error", summary, error: String(err) };
|
||||
}
|
||||
return { status: "ok", summary };
|
||||
}
|
||||
}
|
||||
|
||||
return { status: "ok", summary };
|
||||
}
|
||||
44
src/cron/isolated-agent/session.ts
Normal file
44
src/cron/isolated-agent/session.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "../../config/sessions.js";
|
||||
|
||||
export function resolveCronSession(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
sessionKey: string;
|
||||
nowMs: number;
|
||||
agentId: string;
|
||||
}) {
|
||||
const sessionCfg = params.cfg.session;
|
||||
const idleMinutes = Math.max(
|
||||
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const idleMs = idleMinutes * 60_000;
|
||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const fresh = entry && params.nowMs - entry.updatedAt <= idleMs;
|
||||
const sessionId = fresh ? entry.sessionId : crypto.randomUUID();
|
||||
const systemSent = fresh ? Boolean(entry.systemSent) : false;
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: params.nowMs,
|
||||
systemSent,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model,
|
||||
contextTokens: entry?.contextTokens,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
return { storePath, store, sessionEntry, systemSent, isNewSession: !fresh };
|
||||
}
|
||||
@@ -462,117 +462,4 @@ describe("CronService", () => {
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("skips main jobs with empty systemEvent text", async () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||
await cron.add({
|
||||
name: "empty systemEvent test",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: " " },
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
expect(jobs[0]?.state.lastStatus).toBe("skipped");
|
||||
expect(jobs[0]?.state.lastError).toMatch(/non-empty/i);
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("does not schedule timers when cron is disabled", async () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: false,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||
await cron.add({
|
||||
name: "disabled cron job",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
|
||||
const status = await cron.status();
|
||||
expect(status.enabled).toBe(false);
|
||||
expect(status.nextWakeAtMs).toBeNull();
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
expect(noopLogger.warn).toHaveBeenCalled();
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("status reports next wake when enabled", async () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:05.000Z");
|
||||
await cron.add({
|
||||
name: "status next wake",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
|
||||
const status = await cron.status();
|
||||
expect(status.enabled).toBe(true);
|
||||
expect(status.jobs).toBe(1);
|
||||
expect(status.nextWakeAtMs).toBe(atMs);
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
});
|
||||
152
src/cron/service.part-2.test.ts
Normal file
152
src/cron/service.part-2.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CronService } from "./service.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
async function makeStorePath() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-"));
|
||||
return {
|
||||
storePath: path.join(dir, "cron", "jobs.json"),
|
||||
cleanup: async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CronService", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z"));
|
||||
noopLogger.debug.mockClear();
|
||||
noopLogger.info.mockClear();
|
||||
noopLogger.warn.mockClear();
|
||||
noopLogger.error.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("skips main jobs with empty systemEvent text", async () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||
await cron.add({
|
||||
name: "empty systemEvent test",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: " " },
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
expect(jobs[0]?.state.lastStatus).toBe("skipped");
|
||||
expect(jobs[0]?.state.lastError).toMatch(/non-empty/i);
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("does not schedule timers when cron is disabled", async () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: false,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
|
||||
await cron.add({
|
||||
name: "disabled cron job",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
|
||||
const status = await cron.status();
|
||||
expect(status.enabled).toBe(false);
|
||||
expect(status.nextWakeAtMs).toBeNull();
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
expect(noopLogger.warn).toHaveBeenCalled();
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("status reports next wake when enabled", async () => {
|
||||
const store = await makeStorePath();
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
const requestHeartbeatNow = vi.fn();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent,
|
||||
requestHeartbeatNow,
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })),
|
||||
});
|
||||
|
||||
await cron.start();
|
||||
const atMs = Date.parse("2025-12-13T00:00:05.000Z");
|
||||
await cron.add({
|
||||
name: "status next wake",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
|
||||
const status = await cron.status();
|
||||
expect(status.enabled).toBe(true);
|
||||
expect(status.jobs).toBe(1);
|
||||
expect(status.nextWakeAtMs).toBe(atMs);
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,641 +1,51 @@
|
||||
import crypto from "node:crypto";
|
||||
import * as ops from "./service/ops.js";
|
||||
import {
|
||||
type CronServiceDeps,
|
||||
createCronServiceState,
|
||||
} from "./service/state.js";
|
||||
import type { CronJobCreate, CronJobPatch } from "./types.js";
|
||||
|
||||
import type { HeartbeatRunResult } from "../infra/heartbeat-wake.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import { migrateLegacyCronPayload } from "./payload-migration.js";
|
||||
import { computeNextRunAtMs } from "./schedule.js";
|
||||
import { loadCronStore, saveCronStore } from "./store.js";
|
||||
import type {
|
||||
CronJob,
|
||||
CronJobCreate,
|
||||
CronJobPatch,
|
||||
CronPayload,
|
||||
CronStoreFile,
|
||||
} from "./types.js";
|
||||
|
||||
export type CronEvent = {
|
||||
jobId: string;
|
||||
action: "added" | "updated" | "removed" | "started" | "finished";
|
||||
runAtMs?: number;
|
||||
durationMs?: number;
|
||||
status?: "ok" | "error" | "skipped";
|
||||
error?: string;
|
||||
summary?: string;
|
||||
nextRunAtMs?: number;
|
||||
};
|
||||
|
||||
type Logger = {
|
||||
debug: (obj: unknown, msg?: string) => void;
|
||||
info: (obj: unknown, msg?: string) => void;
|
||||
warn: (obj: unknown, msg?: string) => void;
|
||||
error: (obj: unknown, msg?: string) => void;
|
||||
};
|
||||
|
||||
export type CronServiceDeps = {
|
||||
nowMs?: () => number;
|
||||
log: Logger;
|
||||
storePath: string;
|
||||
cronEnabled: boolean;
|
||||
enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void;
|
||||
requestHeartbeatNow: (opts?: { reason?: string }) => void;
|
||||
runHeartbeatOnce?: (opts?: {
|
||||
reason?: string;
|
||||
}) => Promise<HeartbeatRunResult>;
|
||||
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onEvent?: (evt: CronEvent) => void;
|
||||
};
|
||||
|
||||
type CronServiceDepsInternal = Omit<CronServiceDeps, "nowMs"> & {
|
||||
nowMs: () => number;
|
||||
};
|
||||
|
||||
const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
|
||||
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
||||
|
||||
function normalizeRequiredName(raw: unknown) {
|
||||
if (typeof raw !== "string") throw new Error("cron job name is required");
|
||||
const name = raw.trim();
|
||||
if (!name) throw new Error("cron job name is required");
|
||||
return name;
|
||||
}
|
||||
|
||||
function normalizeOptionalText(raw: unknown) {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function truncateText(input: string, maxLen: number) {
|
||||
if (input.length <= maxLen) return input;
|
||||
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: {
|
||||
schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown };
|
||||
payload?: { kind?: unknown; text?: unknown; message?: unknown };
|
||||
}) {
|
||||
const text =
|
||||
job?.payload?.kind === "systemEvent" && typeof job.payload.text === "string"
|
||||
? job.payload.text
|
||||
: job?.payload?.kind === "agentTurn" &&
|
||||
typeof job.payload.message === "string"
|
||||
? job.payload.message
|
||||
: "";
|
||||
const firstLine =
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.find(Boolean) ?? "";
|
||||
if (firstLine) return truncateText(firstLine, 60);
|
||||
|
||||
const kind = typeof job?.schedule?.kind === "string" ? job.schedule.kind : "";
|
||||
if (kind === "cron" && typeof job?.schedule?.expr === "string")
|
||||
return `Cron: ${truncateText(job.schedule.expr, 52)}`;
|
||||
if (kind === "every" && typeof job?.schedule?.everyMs === "number")
|
||||
return `Every: ${job.schedule.everyMs}ms`;
|
||||
if (kind === "at") return "One-shot";
|
||||
return "Cron job";
|
||||
}
|
||||
|
||||
function normalizePayloadToSystemText(payload: CronPayload) {
|
||||
if (payload.kind === "systemEvent") return payload.text.trim();
|
||||
return payload.message.trim();
|
||||
}
|
||||
export type { CronEvent, CronServiceDeps } from "./service/state.js";
|
||||
|
||||
export class CronService {
|
||||
private readonly deps: CronServiceDepsInternal;
|
||||
private store: CronStoreFile | null = null;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
private op: Promise<unknown> = Promise.resolve();
|
||||
private warnedDisabled = false;
|
||||
|
||||
private readonly state;
|
||||
constructor(deps: CronServiceDeps) {
|
||||
this.deps = {
|
||||
...deps,
|
||||
nowMs: deps.nowMs ?? (() => Date.now()),
|
||||
};
|
||||
this.state = createCronServiceState(deps);
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.locked(async () => {
|
||||
if (!this.deps.cronEnabled) {
|
||||
this.deps.log.info({ enabled: false }, "cron: disabled");
|
||||
return;
|
||||
}
|
||||
await this.ensureLoaded();
|
||||
this.recomputeNextRuns();
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
this.deps.log.info(
|
||||
{
|
||||
enabled: true,
|
||||
jobs: this.store?.jobs.length ?? 0,
|
||||
nextWakeAtMs: this.nextWakeAtMs() ?? null,
|
||||
},
|
||||
"cron: started",
|
||||
);
|
||||
});
|
||||
await ops.start(this.state);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
ops.stop(this.state);
|
||||
}
|
||||
|
||||
async status() {
|
||||
return await this.locked(async () => {
|
||||
await this.ensureLoaded();
|
||||
return {
|
||||
enabled: this.deps.cronEnabled,
|
||||
storePath: this.deps.storePath,
|
||||
jobs: this.store?.jobs.length ?? 0,
|
||||
nextWakeAtMs:
|
||||
this.deps.cronEnabled === true ? (this.nextWakeAtMs() ?? null) : null,
|
||||
};
|
||||
});
|
||||
return await ops.status(this.state);
|
||||
}
|
||||
|
||||
async list(opts?: { includeDisabled?: boolean }) {
|
||||
return await this.locked(async () => {
|
||||
await this.ensureLoaded();
|
||||
const includeDisabled = opts?.includeDisabled === true;
|
||||
const jobs = (this.store?.jobs ?? []).filter(
|
||||
(j) => includeDisabled || j.enabled,
|
||||
);
|
||||
return jobs.sort(
|
||||
(a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0),
|
||||
);
|
||||
});
|
||||
return await ops.list(this.state, opts);
|
||||
}
|
||||
|
||||
async add(input: CronJobCreate) {
|
||||
return await this.locked(async () => {
|
||||
this.warnIfDisabled("add");
|
||||
await this.ensureLoaded();
|
||||
const now = this.deps.nowMs();
|
||||
const id = crypto.randomUUID();
|
||||
const job: CronJob = {
|
||||
id,
|
||||
agentId: normalizeOptionalAgentId(input.agentId),
|
||||
name: normalizeRequiredName(input.name),
|
||||
description: normalizeOptionalText(input.description),
|
||||
enabled: input.enabled !== false,
|
||||
deleteAfterRun: input.deleteAfterRun,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: input.schedule,
|
||||
sessionTarget: input.sessionTarget,
|
||||
wakeMode: input.wakeMode,
|
||||
payload: input.payload,
|
||||
isolation: input.isolation,
|
||||
state: {
|
||||
...input.state,
|
||||
},
|
||||
};
|
||||
this.assertSupportedJobSpec(job);
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
|
||||
this.store?.jobs.push(job);
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
this.emit({
|
||||
jobId: id,
|
||||
action: "added",
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
return job;
|
||||
});
|
||||
return await ops.add(this.state, input);
|
||||
}
|
||||
|
||||
async update(id: string, patch: CronJobPatch) {
|
||||
return await this.locked(async () => {
|
||||
this.warnIfDisabled("update");
|
||||
await this.ensureLoaded();
|
||||
const job = this.findJobOrThrow(id);
|
||||
const now = this.deps.nowMs();
|
||||
|
||||
if ("name" in patch) job.name = normalizeRequiredName(patch.name);
|
||||
if ("description" in patch)
|
||||
job.description = normalizeOptionalText(patch.description);
|
||||
if (typeof patch.enabled === "boolean") job.enabled = patch.enabled;
|
||||
if (typeof patch.deleteAfterRun === "boolean")
|
||||
job.deleteAfterRun = patch.deleteAfterRun;
|
||||
if (patch.schedule) job.schedule = patch.schedule;
|
||||
if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;
|
||||
if (patch.wakeMode) job.wakeMode = patch.wakeMode;
|
||||
if (patch.payload) job.payload = patch.payload;
|
||||
if (patch.isolation) job.isolation = patch.isolation;
|
||||
if (patch.state) job.state = { ...job.state, ...patch.state };
|
||||
if ("agentId" in patch) {
|
||||
job.agentId = normalizeOptionalAgentId(
|
||||
(patch as { agentId?: unknown }).agentId,
|
||||
);
|
||||
}
|
||||
|
||||
job.updatedAtMs = now;
|
||||
this.assertSupportedJobSpec(job);
|
||||
if (job.enabled) {
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
|
||||
} else {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
job.state.runningAtMs = undefined;
|
||||
}
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
this.emit({
|
||||
jobId: id,
|
||||
action: "updated",
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
return job;
|
||||
});
|
||||
return await ops.update(this.state, id, patch);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
return await this.locked(async () => {
|
||||
this.warnIfDisabled("remove");
|
||||
await this.ensureLoaded();
|
||||
const before = this.store?.jobs.length ?? 0;
|
||||
if (!this.store) return { ok: false, removed: false };
|
||||
this.store.jobs = this.store.jobs.filter((j) => j.id !== id);
|
||||
const removed = (this.store.jobs.length ?? 0) !== before;
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
if (removed) this.emit({ jobId: id, action: "removed" });
|
||||
return { ok: true, removed };
|
||||
});
|
||||
return await ops.remove(this.state, id);
|
||||
}
|
||||
|
||||
async run(id: string, mode?: "due" | "force") {
|
||||
return await this.locked(async () => {
|
||||
this.warnIfDisabled("run");
|
||||
await this.ensureLoaded();
|
||||
const job = this.findJobOrThrow(id);
|
||||
const now = this.deps.nowMs();
|
||||
const due =
|
||||
mode === "force" ||
|
||||
(job.enabled &&
|
||||
typeof job.state.nextRunAtMs === "number" &&
|
||||
now >= job.state.nextRunAtMs);
|
||||
if (!due) return { ok: true, ran: false, reason: "not-due" as const };
|
||||
await this.executeJob(job, now, { forced: mode === "force" });
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
return { ok: true, ran: true };
|
||||
});
|
||||
return await ops.run(this.state, id, mode);
|
||||
}
|
||||
|
||||
wake(opts: { mode: "now" | "next-heartbeat"; text: string }) {
|
||||
const text = opts.text.trim();
|
||||
if (!text) return { ok: false };
|
||||
this.deps.enqueueSystemEvent(text);
|
||||
if (opts.mode === "now") {
|
||||
this.deps.requestHeartbeatNow({ reason: "wake" });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async locked<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.op.then(fn, fn);
|
||||
// Keep the chain alive even when the operation fails.
|
||||
this.op = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return (await next) as T;
|
||||
}
|
||||
|
||||
private async ensureLoaded() {
|
||||
if (this.store) return;
|
||||
const loaded = await loadCronStore(this.deps.storePath);
|
||||
const jobs = (loaded.jobs ?? []) as unknown as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
let mutated = false;
|
||||
for (const raw of jobs) {
|
||||
const nameRaw = raw.name;
|
||||
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
|
||||
raw.name = inferLegacyName({
|
||||
schedule: raw.schedule as never,
|
||||
payload: raw.payload as never,
|
||||
});
|
||||
mutated = true;
|
||||
} else {
|
||||
raw.name = nameRaw.trim();
|
||||
}
|
||||
|
||||
const desc = normalizeOptionalText(raw.description);
|
||||
if (raw.description !== desc) {
|
||||
raw.description = desc;
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
const payload = raw.payload;
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
if (migrateLegacyCronPayload(payload as Record<string, unknown>)) {
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.store = { version: 1, jobs: jobs as unknown as CronJob[] };
|
||||
if (mutated) await this.persist();
|
||||
}
|
||||
|
||||
private warnIfDisabled(action: string) {
|
||||
if (this.deps.cronEnabled) return;
|
||||
if (this.warnedDisabled) return;
|
||||
this.warnedDisabled = true;
|
||||
this.deps.log.warn(
|
||||
{ enabled: false, action, storePath: this.deps.storePath },
|
||||
"cron: scheduler disabled; jobs will not run automatically",
|
||||
);
|
||||
}
|
||||
|
||||
private async persist() {
|
||||
if (!this.store) return;
|
||||
await saveCronStore(this.deps.storePath, this.store);
|
||||
}
|
||||
|
||||
private findJobOrThrow(id: string) {
|
||||
const job = this.store?.jobs.find((j) => j.id === id);
|
||||
if (!job) throw new Error(`unknown cron job id: ${id}`);
|
||||
return job;
|
||||
}
|
||||
|
||||
private computeJobNextRunAtMs(job: CronJob, nowMs: number) {
|
||||
if (!job.enabled) return undefined;
|
||||
if (job.schedule.kind === "at") {
|
||||
// One-shot jobs stay due until they successfully finish.
|
||||
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs)
|
||||
return undefined;
|
||||
return job.schedule.atMs;
|
||||
}
|
||||
return computeNextRunAtMs(job.schedule, nowMs);
|
||||
}
|
||||
|
||||
private recomputeNextRuns() {
|
||||
if (!this.store) return;
|
||||
const now = this.deps.nowMs();
|
||||
for (const job of this.store.jobs) {
|
||||
if (!job.state) job.state = {};
|
||||
if (!job.enabled) {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
job.state.runningAtMs = undefined;
|
||||
continue;
|
||||
}
|
||||
const runningAt = job.state.runningAtMs;
|
||||
if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) {
|
||||
this.deps.log.warn(
|
||||
{ jobId: job.id, runningAtMs: runningAt },
|
||||
"cron: clearing stuck running marker",
|
||||
);
|
||||
job.state.runningAtMs = undefined;
|
||||
}
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
|
||||
}
|
||||
}
|
||||
|
||||
private nextWakeAtMs() {
|
||||
const jobs = this.store?.jobs ?? [];
|
||||
const enabled = jobs.filter(
|
||||
(j) => j.enabled && typeof j.state.nextRunAtMs === "number",
|
||||
);
|
||||
if (enabled.length === 0) return undefined;
|
||||
return enabled.reduce(
|
||||
(min, j) => Math.min(min, j.state.nextRunAtMs as number),
|
||||
enabled[0].state.nextRunAtMs as number,
|
||||
);
|
||||
}
|
||||
|
||||
private armTimer() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
if (!this.deps.cronEnabled) return;
|
||||
const nextAt = this.nextWakeAtMs();
|
||||
if (!nextAt) return;
|
||||
const delay = Math.max(nextAt - this.deps.nowMs(), 0);
|
||||
// Avoid TimeoutOverflowWarning when a job is far in the future.
|
||||
const clampedDelay = Math.min(delay, MAX_TIMEOUT_MS);
|
||||
this.timer = setTimeout(() => {
|
||||
void this.onTimer().catch((err) => {
|
||||
this.deps.log.error({ err: String(err) }, "cron: timer tick failed");
|
||||
});
|
||||
}, clampedDelay);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private async onTimer() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
await this.locked(async () => {
|
||||
await this.ensureLoaded();
|
||||
await this.runDueJobs();
|
||||
await this.persist();
|
||||
this.armTimer();
|
||||
});
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runDueJobs() {
|
||||
if (!this.store) return;
|
||||
const now = this.deps.nowMs();
|
||||
const due = this.store.jobs.filter((j) => {
|
||||
if (!j.enabled) return false;
|
||||
if (typeof j.state.runningAtMs === "number") return false;
|
||||
const next = j.state.nextRunAtMs;
|
||||
return typeof next === "number" && now >= next;
|
||||
});
|
||||
for (const job of due) {
|
||||
await this.executeJob(job, now, { forced: false });
|
||||
}
|
||||
}
|
||||
|
||||
private async executeJob(
|
||||
job: CronJob,
|
||||
nowMs: number,
|
||||
opts: { forced: boolean },
|
||||
) {
|
||||
const startedAt = this.deps.nowMs();
|
||||
job.state.runningAtMs = startedAt;
|
||||
job.state.lastError = undefined;
|
||||
this.emit({ jobId: job.id, action: "started", runAtMs: startedAt });
|
||||
|
||||
let deleted = false;
|
||||
|
||||
const finish = async (
|
||||
status: "ok" | "error" | "skipped",
|
||||
err?: string,
|
||||
summary?: string,
|
||||
) => {
|
||||
const endedAt = this.deps.nowMs();
|
||||
job.state.runningAtMs = undefined;
|
||||
job.state.lastRunAtMs = startedAt;
|
||||
job.state.lastStatus = status;
|
||||
job.state.lastDurationMs = Math.max(0, endedAt - startedAt);
|
||||
job.state.lastError = err;
|
||||
|
||||
const shouldDelete =
|
||||
job.schedule.kind === "at" &&
|
||||
status === "ok" &&
|
||||
job.deleteAfterRun === true;
|
||||
|
||||
if (!shouldDelete) {
|
||||
if (job.schedule.kind === "at" && status === "ok") {
|
||||
// One-shot job completed successfully; disable it.
|
||||
job.enabled = false;
|
||||
job.state.nextRunAtMs = undefined;
|
||||
} else if (job.enabled) {
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, endedAt);
|
||||
} else {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({
|
||||
jobId: job.id,
|
||||
action: "finished",
|
||||
status,
|
||||
error: err,
|
||||
summary,
|
||||
runAtMs: startedAt,
|
||||
durationMs: job.state.lastDurationMs,
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
|
||||
if (shouldDelete && this.store) {
|
||||
this.store.jobs = this.store.jobs.filter((j) => j.id !== job.id);
|
||||
deleted = true;
|
||||
this.emit({ jobId: job.id, action: "removed" });
|
||||
}
|
||||
|
||||
if (job.sessionTarget === "isolated") {
|
||||
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
||||
const body = (summary ?? err ?? status).trim();
|
||||
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
||||
this.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
|
||||
agentId: job.agentId,
|
||||
});
|
||||
if (job.wakeMode === "now") {
|
||||
this.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (job.sessionTarget === "main") {
|
||||
if (job.payload.kind !== "systemEvent") {
|
||||
await finish(
|
||||
"skipped",
|
||||
'main job requires payload.kind="systemEvent"',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const text = normalizePayloadToSystemText(job.payload);
|
||||
if (!text) {
|
||||
await finish(
|
||||
"skipped",
|
||||
"main job requires non-empty systemEvent text",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.deps.enqueueSystemEvent(text, { agentId: job.agentId });
|
||||
if (job.wakeMode === "now" && this.deps.runHeartbeatOnce) {
|
||||
const reason = `cron:${job.id}`;
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
const maxWaitMs = 2 * 60_000;
|
||||
const waitStartedAt = this.deps.nowMs();
|
||||
|
||||
let heartbeatResult: HeartbeatRunResult;
|
||||
for (;;) {
|
||||
heartbeatResult = await this.deps.runHeartbeatOnce({ reason });
|
||||
if (
|
||||
heartbeatResult.status !== "skipped" ||
|
||||
heartbeatResult.reason !== "requests-in-flight"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (this.deps.nowMs() - waitStartedAt > maxWaitMs) {
|
||||
heartbeatResult = {
|
||||
status: "skipped",
|
||||
reason: "timeout waiting for main lane to become idle",
|
||||
};
|
||||
break;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
if (heartbeatResult.status === "ran") {
|
||||
await finish("ok", undefined, text);
|
||||
} else if (heartbeatResult.status === "skipped")
|
||||
await finish("skipped", heartbeatResult.reason, text);
|
||||
else await finish("error", heartbeatResult.reason, text);
|
||||
} else {
|
||||
// wakeMode is "next-heartbeat" or runHeartbeatOnce not available
|
||||
this.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
|
||||
await finish("ok", undefined, text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.payload.kind !== "agentTurn") {
|
||||
await finish("skipped", "isolated job requires payload.kind=agentTurn");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await this.deps.runIsolatedAgentJob({
|
||||
job,
|
||||
message: job.payload.message,
|
||||
});
|
||||
if (res.status === "ok") await finish("ok", undefined, res.summary);
|
||||
else if (res.status === "skipped")
|
||||
await finish("skipped", undefined, res.summary);
|
||||
else await finish("error", res.error ?? "cron job failed", res.summary);
|
||||
} catch (err) {
|
||||
await finish("error", String(err));
|
||||
} finally {
|
||||
job.updatedAtMs = nowMs;
|
||||
if (!opts.forced && job.enabled && !deleted) {
|
||||
// Keep nextRunAtMs in sync in case the schedule advanced during a long run.
|
||||
job.state.nextRunAtMs = this.computeJobNextRunAtMs(
|
||||
job,
|
||||
this.deps.nowMs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(evt: CronEvent) {
|
||||
try {
|
||||
this.deps.onEvent?.(evt);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
private assertSupportedJobSpec(
|
||||
job: Pick<CronJob, "sessionTarget" | "payload">,
|
||||
) {
|
||||
if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") {
|
||||
throw new Error('main cron jobs require payload.kind="systemEvent"');
|
||||
}
|
||||
if (job.sessionTarget === "isolated" && job.payload.kind !== "agentTurn") {
|
||||
throw new Error('isolated cron jobs require payload.kind="agentTurn"');
|
||||
}
|
||||
return ops.wakeNow(this.state, opts);
|
||||
}
|
||||
}
|
||||
|
||||
147
src/cron/service/jobs.ts
Normal file
147
src/cron/service/jobs.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { computeNextRunAtMs } from "../schedule.js";
|
||||
import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js";
|
||||
import {
|
||||
normalizeOptionalAgentId,
|
||||
normalizeOptionalText,
|
||||
normalizePayloadToSystemText,
|
||||
normalizeRequiredName,
|
||||
} from "./normalize.js";
|
||||
import type { CronServiceState } from "./state.js";
|
||||
|
||||
const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
export function assertSupportedJobSpec(
|
||||
job: Pick<CronJob, "sessionTarget" | "payload">,
|
||||
) {
|
||||
if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") {
|
||||
throw new Error('main cron jobs require payload.kind="systemEvent"');
|
||||
}
|
||||
if (job.sessionTarget === "isolated" && job.payload.kind !== "agentTurn") {
|
||||
throw new Error('isolated cron jobs require payload.kind="agentTurn"');
|
||||
}
|
||||
}
|
||||
|
||||
export function findJobOrThrow(state: CronServiceState, id: string) {
|
||||
const job = state.store?.jobs.find((j) => j.id === id);
|
||||
if (!job) throw new Error(`unknown cron job id: ${id}`);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function computeJobNextRunAtMs(
|
||||
job: CronJob,
|
||||
nowMs: number,
|
||||
): number | undefined {
|
||||
if (!job.enabled) return undefined;
|
||||
if (job.schedule.kind === "at") {
|
||||
// One-shot jobs stay due until they successfully finish.
|
||||
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs)
|
||||
return undefined;
|
||||
return job.schedule.atMs;
|
||||
}
|
||||
return computeNextRunAtMs(job.schedule, nowMs);
|
||||
}
|
||||
|
||||
export function recomputeNextRuns(state: CronServiceState) {
|
||||
if (!state.store) return;
|
||||
const now = state.deps.nowMs();
|
||||
for (const job of state.store.jobs) {
|
||||
if (!job.state) job.state = {};
|
||||
if (!job.enabled) {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
job.state.runningAtMs = undefined;
|
||||
continue;
|
||||
}
|
||||
const runningAt = job.state.runningAtMs;
|
||||
if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) {
|
||||
state.deps.log.warn(
|
||||
{ jobId: job.id, runningAtMs: runningAt },
|
||||
"cron: clearing stuck running marker",
|
||||
);
|
||||
job.state.runningAtMs = undefined;
|
||||
}
|
||||
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
|
||||
}
|
||||
}
|
||||
|
||||
export function nextWakeAtMs(state: CronServiceState) {
|
||||
const jobs = state.store?.jobs ?? [];
|
||||
const enabled = jobs.filter(
|
||||
(j) => j.enabled && typeof j.state.nextRunAtMs === "number",
|
||||
);
|
||||
if (enabled.length === 0) return undefined;
|
||||
return enabled.reduce(
|
||||
(min, j) => Math.min(min, j.state.nextRunAtMs as number),
|
||||
enabled[0].state.nextRunAtMs as number,
|
||||
);
|
||||
}
|
||||
|
||||
export function createJob(
|
||||
state: CronServiceState,
|
||||
input: CronJobCreate,
|
||||
): CronJob {
|
||||
const now = state.deps.nowMs();
|
||||
const id = crypto.randomUUID();
|
||||
const job: CronJob = {
|
||||
id,
|
||||
agentId: normalizeOptionalAgentId(input.agentId),
|
||||
name: normalizeRequiredName(input.name),
|
||||
description: normalizeOptionalText(input.description),
|
||||
enabled: input.enabled !== false,
|
||||
deleteAfterRun: input.deleteAfterRun,
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
schedule: input.schedule,
|
||||
sessionTarget: input.sessionTarget,
|
||||
wakeMode: input.wakeMode,
|
||||
payload: input.payload,
|
||||
isolation: input.isolation,
|
||||
state: {
|
||||
...input.state,
|
||||
},
|
||||
};
|
||||
assertSupportedJobSpec(job);
|
||||
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
|
||||
if ("name" in patch) job.name = normalizeRequiredName(patch.name);
|
||||
if ("description" in patch)
|
||||
job.description = normalizeOptionalText(patch.description);
|
||||
if (typeof patch.enabled === "boolean") job.enabled = patch.enabled;
|
||||
if (typeof patch.deleteAfterRun === "boolean")
|
||||
job.deleteAfterRun = patch.deleteAfterRun;
|
||||
if (patch.schedule) job.schedule = patch.schedule;
|
||||
if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget;
|
||||
if (patch.wakeMode) job.wakeMode = patch.wakeMode;
|
||||
if (patch.payload) job.payload = patch.payload;
|
||||
if (patch.isolation) job.isolation = patch.isolation;
|
||||
if (patch.state) job.state = { ...job.state, ...patch.state };
|
||||
if ("agentId" in patch) {
|
||||
job.agentId = normalizeOptionalAgentId(
|
||||
(patch as { agentId?: unknown }).agentId,
|
||||
);
|
||||
}
|
||||
assertSupportedJobSpec(job);
|
||||
}
|
||||
|
||||
export function isJobDue(
|
||||
job: CronJob,
|
||||
nowMs: number,
|
||||
opts: { forced: boolean },
|
||||
) {
|
||||
if (opts.forced) return true;
|
||||
return (
|
||||
job.enabled &&
|
||||
typeof job.state.nextRunAtMs === "number" &&
|
||||
nowMs >= job.state.nextRunAtMs
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveJobPayloadTextForMain(job: CronJob): string | undefined {
|
||||
if (job.payload.kind !== "systemEvent") return undefined;
|
||||
const text = normalizePayloadToSystemText(job.payload);
|
||||
return text.trim() ? text : undefined;
|
||||
}
|
||||
14
src/cron/service/locked.ts
Normal file
14
src/cron/service/locked.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CronServiceState } from "./state.js";
|
||||
|
||||
export async function locked<T>(
|
||||
state: CronServiceState,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const next = state.op.then(fn, fn);
|
||||
// Keep the chain alive even when the operation fails.
|
||||
state.op = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return (await next) as T;
|
||||
}
|
||||
60
src/cron/service/normalize.ts
Normal file
60
src/cron/service/normalize.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import type { CronPayload } from "../types.js";
|
||||
|
||||
export function normalizeRequiredName(raw: unknown) {
|
||||
if (typeof raw !== "string") throw new Error("cron job name is required");
|
||||
const name = raw.trim();
|
||||
if (!name) throw new Error("cron job name is required");
|
||||
return name;
|
||||
}
|
||||
|
||||
export function normalizeOptionalText(raw: unknown) {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function truncateText(input: string, maxLen: number) {
|
||||
if (input.length <= maxLen) return input;
|
||||
return `${truncateUtf16Safe(input, Math.max(0, maxLen - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function normalizeOptionalAgentId(raw: unknown) {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return normalizeAgentId(trimmed);
|
||||
}
|
||||
|
||||
export function inferLegacyName(job: {
|
||||
schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown };
|
||||
payload?: { kind?: unknown; text?: unknown; message?: unknown };
|
||||
}) {
|
||||
const text =
|
||||
job?.payload?.kind === "systemEvent" && typeof job.payload.text === "string"
|
||||
? job.payload.text
|
||||
: job?.payload?.kind === "agentTurn" &&
|
||||
typeof job.payload.message === "string"
|
||||
? job.payload.message
|
||||
: "";
|
||||
const firstLine =
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.find(Boolean) ?? "";
|
||||
if (firstLine) return truncateText(firstLine, 60);
|
||||
|
||||
const kind = typeof job?.schedule?.kind === "string" ? job.schedule.kind : "";
|
||||
if (kind === "cron" && typeof job?.schedule?.expr === "string")
|
||||
return `Cron: ${truncateText(job.schedule.expr, 52)}`;
|
||||
if (kind === "every" && typeof job?.schedule?.everyMs === "number")
|
||||
return `Every: ${job.schedule.everyMs}ms`;
|
||||
if (kind === "at") return "One-shot";
|
||||
return "Cron job";
|
||||
}
|
||||
|
||||
export function normalizePayloadToSystemText(payload: CronPayload) {
|
||||
if (payload.kind === "systemEvent") return payload.text.trim();
|
||||
return payload.message.trim();
|
||||
}
|
||||
156
src/cron/service/ops.ts
Normal file
156
src/cron/service/ops.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { CronJobCreate, CronJobPatch } from "../types.js";
|
||||
import {
|
||||
applyJobPatch,
|
||||
computeJobNextRunAtMs,
|
||||
createJob,
|
||||
findJobOrThrow,
|
||||
isJobDue,
|
||||
nextWakeAtMs,
|
||||
recomputeNextRuns,
|
||||
} from "./jobs.js";
|
||||
import { locked } from "./locked.js";
|
||||
import type { CronServiceState } from "./state.js";
|
||||
import { ensureLoaded, persist, warnIfDisabled } from "./store.js";
|
||||
import { armTimer, emit, executeJob, stopTimer, wake } from "./timer.js";
|
||||
|
||||
export async function start(state: CronServiceState) {
|
||||
await locked(state, async () => {
|
||||
if (!state.deps.cronEnabled) {
|
||||
state.deps.log.info({ enabled: false }, "cron: disabled");
|
||||
return;
|
||||
}
|
||||
await ensureLoaded(state);
|
||||
recomputeNextRuns(state);
|
||||
await persist(state);
|
||||
armTimer(state);
|
||||
state.deps.log.info(
|
||||
{
|
||||
enabled: true,
|
||||
jobs: state.store?.jobs.length ?? 0,
|
||||
nextWakeAtMs: nextWakeAtMs(state) ?? null,
|
||||
},
|
||||
"cron: started",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function stop(state: CronServiceState) {
|
||||
stopTimer(state);
|
||||
}
|
||||
|
||||
export async function status(state: CronServiceState) {
|
||||
return await locked(state, async () => {
|
||||
await ensureLoaded(state);
|
||||
return {
|
||||
enabled: state.deps.cronEnabled,
|
||||
storePath: state.deps.storePath,
|
||||
jobs: state.store?.jobs.length ?? 0,
|
||||
nextWakeAtMs:
|
||||
state.deps.cronEnabled === true ? (nextWakeAtMs(state) ?? null) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function list(
|
||||
state: CronServiceState,
|
||||
opts?: { includeDisabled?: boolean },
|
||||
) {
|
||||
return await locked(state, async () => {
|
||||
await ensureLoaded(state);
|
||||
const includeDisabled = opts?.includeDisabled === true;
|
||||
const jobs = (state.store?.jobs ?? []).filter(
|
||||
(j) => includeDisabled || j.enabled,
|
||||
);
|
||||
return jobs.sort(
|
||||
(a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function add(state: CronServiceState, input: CronJobCreate) {
|
||||
return await locked(state, async () => {
|
||||
warnIfDisabled(state, "add");
|
||||
await ensureLoaded(state);
|
||||
const job = createJob(state, input);
|
||||
state.store?.jobs.push(job);
|
||||
await persist(state);
|
||||
armTimer(state);
|
||||
emit(state, {
|
||||
jobId: job.id,
|
||||
action: "added",
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
return job;
|
||||
});
|
||||
}
|
||||
|
||||
export async function update(
|
||||
state: CronServiceState,
|
||||
id: string,
|
||||
patch: CronJobPatch,
|
||||
) {
|
||||
return await locked(state, async () => {
|
||||
warnIfDisabled(state, "update");
|
||||
await ensureLoaded(state);
|
||||
const job = findJobOrThrow(state, id);
|
||||
const now = state.deps.nowMs();
|
||||
applyJobPatch(job, patch);
|
||||
job.updatedAtMs = now;
|
||||
if (job.enabled) {
|
||||
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
|
||||
} else {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
job.state.runningAtMs = undefined;
|
||||
}
|
||||
|
||||
await persist(state);
|
||||
armTimer(state);
|
||||
emit(state, {
|
||||
jobId: id,
|
||||
action: "updated",
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
return job;
|
||||
});
|
||||
}
|
||||
|
||||
export async function remove(state: CronServiceState, id: string) {
|
||||
return await locked(state, async () => {
|
||||
warnIfDisabled(state, "remove");
|
||||
await ensureLoaded(state);
|
||||
const before = state.store?.jobs.length ?? 0;
|
||||
if (!state.store) return { ok: false, removed: false } as const;
|
||||
state.store.jobs = state.store.jobs.filter((j) => j.id !== id);
|
||||
const removed = (state.store.jobs.length ?? 0) !== before;
|
||||
await persist(state);
|
||||
armTimer(state);
|
||||
if (removed) emit(state, { jobId: id, action: "removed" });
|
||||
return { ok: true, removed } as const;
|
||||
});
|
||||
}
|
||||
|
||||
export async function run(
|
||||
state: CronServiceState,
|
||||
id: string,
|
||||
mode?: "due" | "force",
|
||||
) {
|
||||
return await locked(state, async () => {
|
||||
warnIfDisabled(state, "run");
|
||||
await ensureLoaded(state);
|
||||
const job = findJobOrThrow(state, id);
|
||||
const now = state.deps.nowMs();
|
||||
const due = isJobDue(job, now, { forced: mode === "force" });
|
||||
if (!due) return { ok: true, ran: false, reason: "not-due" as const };
|
||||
await executeJob(state, job, now, { forced: mode === "force" });
|
||||
await persist(state);
|
||||
armTimer(state);
|
||||
return { ok: true, ran: true } as const;
|
||||
});
|
||||
}
|
||||
|
||||
export function wakeNow(
|
||||
state: CronServiceState,
|
||||
opts: { mode: "now" | "next-heartbeat"; text: string },
|
||||
) {
|
||||
return wake(state, opts);
|
||||
}
|
||||
95
src/cron/service/state.ts
Normal file
95
src/cron/service/state.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
|
||||
import type {
|
||||
CronJob,
|
||||
CronJobCreate,
|
||||
CronJobPatch,
|
||||
CronStoreFile,
|
||||
} from "../types.js";
|
||||
|
||||
export type CronEvent = {
|
||||
jobId: string;
|
||||
action: "added" | "updated" | "removed" | "started" | "finished";
|
||||
runAtMs?: number;
|
||||
durationMs?: number;
|
||||
status?: "ok" | "error" | "skipped";
|
||||
error?: string;
|
||||
summary?: string;
|
||||
nextRunAtMs?: number;
|
||||
};
|
||||
|
||||
export type Logger = {
|
||||
debug: (obj: unknown, msg?: string) => void;
|
||||
info: (obj: unknown, msg?: string) => void;
|
||||
warn: (obj: unknown, msg?: string) => void;
|
||||
error: (obj: unknown, msg?: string) => void;
|
||||
};
|
||||
|
||||
export type CronServiceDeps = {
|
||||
nowMs?: () => number;
|
||||
log: Logger;
|
||||
storePath: string;
|
||||
cronEnabled: boolean;
|
||||
enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void;
|
||||
requestHeartbeatNow: (opts?: { reason?: string }) => void;
|
||||
runHeartbeatOnce?: (opts?: {
|
||||
reason?: string;
|
||||
}) => Promise<HeartbeatRunResult>;
|
||||
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onEvent?: (evt: CronEvent) => void;
|
||||
};
|
||||
|
||||
export type CronServiceDepsInternal = Omit<CronServiceDeps, "nowMs"> & {
|
||||
nowMs: () => number;
|
||||
};
|
||||
|
||||
export type CronServiceState = {
|
||||
deps: CronServiceDepsInternal;
|
||||
store: CronStoreFile | null;
|
||||
timer: NodeJS.Timeout | null;
|
||||
running: boolean;
|
||||
op: Promise<unknown>;
|
||||
warnedDisabled: boolean;
|
||||
};
|
||||
|
||||
export function createCronServiceState(
|
||||
deps: CronServiceDeps,
|
||||
): CronServiceState {
|
||||
return {
|
||||
deps: { ...deps, nowMs: deps.nowMs ?? (() => Date.now()) },
|
||||
store: null,
|
||||
timer: null,
|
||||
running: false,
|
||||
op: Promise.resolve(),
|
||||
warnedDisabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
export type CronRunMode = "due" | "force";
|
||||
export type CronWakeMode = "now" | "next-heartbeat";
|
||||
|
||||
export type CronStatusSummary = {
|
||||
enabled: boolean;
|
||||
storePath: string;
|
||||
jobs: number;
|
||||
nextWakeAtMs: number | null;
|
||||
};
|
||||
|
||||
export type CronRunResult =
|
||||
| { ok: true; ran: true }
|
||||
| { ok: true; ran: false; reason: "not-due" }
|
||||
| { ok: false };
|
||||
|
||||
export type CronRemoveResult =
|
||||
| { ok: true; removed: boolean }
|
||||
| { ok: false; removed: false };
|
||||
|
||||
export type CronAddResult = CronJob;
|
||||
export type CronUpdateResult = CronJob;
|
||||
|
||||
export type CronListResult = CronJob[];
|
||||
export type CronAddInput = CronJobCreate;
|
||||
export type CronUpdateInput = CronJobPatch;
|
||||
54
src/cron/service/store.ts
Normal file
54
src/cron/service/store.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { migrateLegacyCronPayload } from "../payload-migration.js";
|
||||
import { loadCronStore, saveCronStore } from "../store.js";
|
||||
import type { CronJob } from "../types.js";
|
||||
import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
|
||||
import type { CronServiceState } from "./state.js";
|
||||
|
||||
export async function ensureLoaded(state: CronServiceState) {
|
||||
if (state.store) return;
|
||||
const loaded = await loadCronStore(state.deps.storePath);
|
||||
const jobs = (loaded.jobs ?? []) as unknown as Array<Record<string, unknown>>;
|
||||
let mutated = false;
|
||||
for (const raw of jobs) {
|
||||
const nameRaw = raw.name;
|
||||
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
|
||||
raw.name = inferLegacyName({
|
||||
schedule: raw.schedule as never,
|
||||
payload: raw.payload as never,
|
||||
});
|
||||
mutated = true;
|
||||
} else {
|
||||
raw.name = nameRaw.trim();
|
||||
}
|
||||
|
||||
const desc = normalizeOptionalText(raw.description);
|
||||
if (raw.description !== desc) {
|
||||
raw.description = desc;
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
const payload = raw.payload;
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
if (migrateLegacyCronPayload(payload as Record<string, unknown>)) {
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.store = { version: 1, jobs: jobs as unknown as CronJob[] };
|
||||
if (mutated) await persist(state);
|
||||
}
|
||||
|
||||
export function warnIfDisabled(state: CronServiceState, action: string) {
|
||||
if (state.deps.cronEnabled) return;
|
||||
if (state.warnedDisabled) return;
|
||||
state.warnedDisabled = true;
|
||||
state.deps.log.warn(
|
||||
{ enabled: false, action, storePath: state.deps.storePath },
|
||||
"cron: scheduler disabled; jobs will not run automatically",
|
||||
);
|
||||
}
|
||||
|
||||
export async function persist(state: CronServiceState) {
|
||||
if (!state.store) return;
|
||||
await saveCronStore(state.deps.storePath, state.store);
|
||||
}
|
||||
235
src/cron/service/timer.ts
Normal file
235
src/cron/service/timer.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
|
||||
import type { CronJob } from "../types.js";
|
||||
import {
|
||||
computeJobNextRunAtMs,
|
||||
nextWakeAtMs,
|
||||
resolveJobPayloadTextForMain,
|
||||
} from "./jobs.js";
|
||||
import { locked } from "./locked.js";
|
||||
import type { CronEvent, CronServiceState } from "./state.js";
|
||||
import { ensureLoaded, persist } from "./store.js";
|
||||
|
||||
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
||||
|
||||
export function armTimer(state: CronServiceState) {
|
||||
if (state.timer) clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
if (!state.deps.cronEnabled) return;
|
||||
const nextAt = nextWakeAtMs(state);
|
||||
if (!nextAt) return;
|
||||
const delay = Math.max(nextAt - state.deps.nowMs(), 0);
|
||||
// Avoid TimeoutOverflowWarning when a job is far in the future.
|
||||
const clampedDelay = Math.min(delay, MAX_TIMEOUT_MS);
|
||||
state.timer = setTimeout(() => {
|
||||
void onTimer(state).catch((err) => {
|
||||
state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
|
||||
});
|
||||
}, clampedDelay);
|
||||
state.timer.unref?.();
|
||||
}
|
||||
|
||||
export async function onTimer(state: CronServiceState) {
|
||||
if (state.running) return;
|
||||
state.running = true;
|
||||
try {
|
||||
await locked(state, async () => {
|
||||
await ensureLoaded(state);
|
||||
await runDueJobs(state);
|
||||
await persist(state);
|
||||
armTimer(state);
|
||||
});
|
||||
} finally {
|
||||
state.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDueJobs(state: CronServiceState) {
|
||||
if (!state.store) return;
|
||||
const now = state.deps.nowMs();
|
||||
const due = state.store.jobs.filter((j) => {
|
||||
if (!j.enabled) return false;
|
||||
if (typeof j.state.runningAtMs === "number") return false;
|
||||
const next = j.state.nextRunAtMs;
|
||||
return typeof next === "number" && now >= next;
|
||||
});
|
||||
for (const job of due) {
|
||||
await executeJob(state, job, now, { forced: false });
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeJob(
|
||||
state: CronServiceState,
|
||||
job: CronJob,
|
||||
nowMs: number,
|
||||
opts: { forced: boolean },
|
||||
) {
|
||||
const startedAt = state.deps.nowMs();
|
||||
job.state.runningAtMs = startedAt;
|
||||
job.state.lastError = undefined;
|
||||
emit(state, { jobId: job.id, action: "started", runAtMs: startedAt });
|
||||
|
||||
let deleted = false;
|
||||
|
||||
const finish = async (
|
||||
status: "ok" | "error" | "skipped",
|
||||
err?: string,
|
||||
summary?: string,
|
||||
) => {
|
||||
const endedAt = state.deps.nowMs();
|
||||
job.state.runningAtMs = undefined;
|
||||
job.state.lastRunAtMs = startedAt;
|
||||
job.state.lastStatus = status;
|
||||
job.state.lastDurationMs = Math.max(0, endedAt - startedAt);
|
||||
job.state.lastError = err;
|
||||
|
||||
const shouldDelete =
|
||||
job.schedule.kind === "at" &&
|
||||
status === "ok" &&
|
||||
job.deleteAfterRun === true;
|
||||
|
||||
if (!shouldDelete) {
|
||||
if (job.schedule.kind === "at" && status === "ok") {
|
||||
// One-shot job completed successfully; disable it.
|
||||
job.enabled = false;
|
||||
job.state.nextRunAtMs = undefined;
|
||||
} else if (job.enabled) {
|
||||
job.state.nextRunAtMs = computeJobNextRunAtMs(job, endedAt);
|
||||
} else {
|
||||
job.state.nextRunAtMs = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
emit(state, {
|
||||
jobId: job.id,
|
||||
action: "finished",
|
||||
status,
|
||||
error: err,
|
||||
summary,
|
||||
runAtMs: startedAt,
|
||||
durationMs: job.state.lastDurationMs,
|
||||
nextRunAtMs: job.state.nextRunAtMs,
|
||||
});
|
||||
|
||||
if (shouldDelete && state.store) {
|
||||
state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id);
|
||||
deleted = true;
|
||||
emit(state, { jobId: job.id, action: "removed" });
|
||||
}
|
||||
|
||||
if (job.sessionTarget === "isolated") {
|
||||
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
||||
const body = (summary ?? err ?? status).trim();
|
||||
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
||||
state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
|
||||
agentId: job.agentId,
|
||||
});
|
||||
if (job.wakeMode === "now") {
|
||||
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (job.sessionTarget === "main") {
|
||||
const text = resolveJobPayloadTextForMain(job);
|
||||
if (!text) {
|
||||
const kind = job.payload.kind;
|
||||
await finish(
|
||||
"skipped",
|
||||
kind === "systemEvent"
|
||||
? "main job requires non-empty systemEvent text"
|
||||
: 'main job requires payload.kind="systemEvent"',
|
||||
);
|
||||
return;
|
||||
}
|
||||
state.deps.enqueueSystemEvent(text, { agentId: job.agentId });
|
||||
if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) {
|
||||
const reason = `cron:${job.id}`;
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
const maxWaitMs = 2 * 60_000;
|
||||
const waitStartedAt = state.deps.nowMs();
|
||||
|
||||
let heartbeatResult: HeartbeatRunResult;
|
||||
for (;;) {
|
||||
heartbeatResult = await state.deps.runHeartbeatOnce({ reason });
|
||||
if (
|
||||
heartbeatResult.status !== "skipped" ||
|
||||
heartbeatResult.reason !== "requests-in-flight"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (state.deps.nowMs() - waitStartedAt > maxWaitMs) {
|
||||
heartbeatResult = {
|
||||
status: "skipped",
|
||||
reason: "timeout waiting for main lane to become idle",
|
||||
};
|
||||
break;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
if (heartbeatResult.status === "ran") {
|
||||
await finish("ok", undefined, text);
|
||||
} else if (heartbeatResult.status === "skipped") {
|
||||
await finish("skipped", heartbeatResult.reason, text);
|
||||
} else {
|
||||
await finish("error", heartbeatResult.reason, text);
|
||||
}
|
||||
} else {
|
||||
// wakeMode is "next-heartbeat" or runHeartbeatOnce not available
|
||||
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
|
||||
await finish("ok", undefined, text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.payload.kind !== "agentTurn") {
|
||||
await finish("skipped", "isolated job requires payload.kind=agentTurn");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await state.deps.runIsolatedAgentJob({
|
||||
job,
|
||||
message: job.payload.message,
|
||||
});
|
||||
if (res.status === "ok") await finish("ok", undefined, res.summary);
|
||||
else if (res.status === "skipped")
|
||||
await finish("skipped", undefined, res.summary);
|
||||
else await finish("error", res.error ?? "cron job failed", res.summary);
|
||||
} catch (err) {
|
||||
await finish("error", String(err));
|
||||
} finally {
|
||||
job.updatedAtMs = nowMs;
|
||||
if (!opts.forced && job.enabled && !deleted) {
|
||||
// Keep nextRunAtMs in sync in case the schedule advanced during a long run.
|
||||
job.state.nextRunAtMs = computeJobNextRunAtMs(job, state.deps.nowMs());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function wake(
|
||||
state: CronServiceState,
|
||||
opts: { mode: "now" | "next-heartbeat"; text: string },
|
||||
) {
|
||||
const text = opts.text.trim();
|
||||
if (!text) return { ok: false } as const;
|
||||
state.deps.enqueueSystemEvent(text);
|
||||
if (opts.mode === "now") {
|
||||
state.deps.requestHeartbeatNow({ reason: "wake" });
|
||||
}
|
||||
return { ok: true } as const;
|
||||
}
|
||||
|
||||
export function stopTimer(state: CronServiceState) {
|
||||
if (state.timer) clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
|
||||
export function emit(state: CronServiceState, evt: CronEvent) {
|
||||
try {
|
||||
state.deps.onEvent?.(evt);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user