fix(heartbeat): keep due task runs tool-capable

Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-04-30 22:44:24 -07:00
committed by GitHub
parent 173f959613
commit 955a0e9c0f
2 changed files with 119 additions and 18 deletions

View File

@@ -153,6 +153,93 @@ describe("runHeartbeatOnce commitments", () => {
});
}
it("keeps due heartbeat tasks tool-capable when commitments are also due", async () => {
const { result, sendTelegram, store } = await withTempHeartbeatSandbox(
async ({ tmpDir, storePath, replySpy }) => {
vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir);
const sessionKey = "agent:main:telegram:user-155462274";
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "last",
},
},
},
channels: { telegram: { allowFrom: ["*"] } },
session: { store: storePath },
commitments: { enabled: true },
};
await fs.writeFile(
path.join(tmpDir, "HEARTBEAT.md"),
`tasks:
- name: deployment-status
interval: 5m
prompt: Check deployment status with the normal tools
`,
"utf-8",
);
await seedSessionStore(storePath, sessionKey, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: "stale-target",
});
await saveCommitmentStore(undefined, {
version: 1,
commitments: [buildCommitment({ id: "cm_interview", sessionKey, to: "155462274" })],
});
const sendTelegram = vi.fn().mockResolvedValue({
messageId: "m1",
chatId: "stale-target",
});
replySpy.mockImplementation(
async (
ctx: { Body?: string; OriginatingChannel?: string; OriginatingTo?: string },
opts?: { disableTools?: boolean; skillFilter?: string[] },
) => {
expect(ctx.Body).toContain("Run the following periodic tasks");
expect(ctx.Body).toContain("- deployment-status: Check deployment status");
expect(ctx.Body).not.toContain("Due inferred follow-up commitments");
expect(ctx.OriginatingChannel).toBe("telegram");
expect(ctx.OriginatingTo).toBe("stale-target");
expect(opts?.disableTools).toBeUndefined();
expect(opts?.skillFilter).toBeUndefined();
return { text: "Deployment status checked" };
},
);
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
sessionKey,
deps: {
getReplyFromConfig: replySpy,
telegram: sendTelegram,
getQueueSize: () => 0,
nowMs: () => nowMs,
},
});
return {
result,
sendTelegram,
store: await loadCommitmentStore(),
};
},
);
expect(result.status).toBe("ran");
expect(sendTelegram).toHaveBeenCalled();
expect(store.commitments[0]).toMatchObject({
id: "cm_interview",
status: "pending",
attempts: 0,
});
});
it("does not deliver due commitments when heartbeat target is none", async () => {
const { result, sendTelegram, store } = await withTempHeartbeatSandbox(
async ({ tmpDir, storePath, replySpy }) => {

View File

@@ -716,8 +716,7 @@ async function resolveHeartbeatPreflight(params: {
reasonFlags.isExecEventReason ||
reasonFlags.isCronEventReason ||
reasonFlags.isWakeReason ||
hasTaggedCronEvents ||
dueCommitments.length > 0;
hasTaggedCronEvents;
const basePreflight = {
...reasonFlags,
session,
@@ -738,7 +737,11 @@ async function resolveHeartbeatPreflight(params: {
try {
heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
const tasks = parseHeartbeatTasks(heartbeatFileContent);
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && tasks.length === 0) {
if (
isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
tasks.length === 0 &&
dueCommitments.length === 0
) {
return {
...basePreflight,
skipReason: "empty-heartbeat-file",
@@ -773,6 +776,23 @@ type HeartbeatPromptResolution = {
hasDueCommitments: boolean;
};
function resolveDueHeartbeatTasks(
preflight: Pick<HeartbeatPreflight, "session" | "tasks">,
startedAt: number,
): HeartbeatTask[] {
const tasks = preflight.tasks;
if (!tasks || tasks.length === 0) {
return [];
}
return tasks.filter((task) =>
isTaskDue(
(preflight.session.entry?.heartbeatTaskState as Record<string, number>)?.[task.name],
task.interval,
startedAt,
),
);
}
function appendHeartbeatWorkspacePathHint(prompt: string, workspaceDir: string): string {
if (!/heartbeat\.md/i.test(prompt)) {
return prompt;
@@ -821,6 +841,7 @@ function resolveHeartbeatRunPrompt(params: {
canRelayToUser: boolean;
workspaceDir: string;
startedAt: number;
dueTasks: HeartbeatTask[];
heartbeatFileContent?: string;
}): HeartbeatPromptResolution {
const pendingEventEntries = params.preflight.pendingEventEntries;
@@ -844,14 +865,7 @@ function resolveHeartbeatRunPrompt(params: {
const hasDueCommitments = Boolean(commitmentPrompt);
if (params.preflight.tasks && params.preflight.tasks.length > 0) {
const tasks = params.preflight.tasks;
const dueTasks = tasks.filter((task) =>
isTaskDue(
(params.preflight.session.entry?.heartbeatTaskState as Record<string, number>)?.[task.name],
task.interval,
params.startedAt,
),
);
const dueTasks = params.dueTasks;
if (dueTasks.length > 0) {
const taskList = dueTasks.map((task) => `- ${task.name}: ${task.prompt}`).join("\n");
@@ -867,15 +881,12 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
prompt += `\n\nAdditional context from HEARTBEAT.md:\n${directives}`;
}
}
if (commitmentPrompt) {
prompt += `\n\n${commitmentPrompt}`;
}
return {
prompt,
hasExecCompletion: false,
hasRelayableExecCompletion: false,
hasCronEvents: false,
hasDueCommitments,
hasDueCommitments: false,
};
}
if (commitmentPrompt) {
@@ -1002,6 +1013,7 @@ export async function runHeartbeatOnce(opts: {
}
const previousUpdatedAt = entry?.updatedAt;
const dueHeartbeatTasks = resolveDueHeartbeatTasks(preflight, startedAt);
// When isolatedSession is enabled, create a fresh session via the same
// pattern as cron sessionTarget: "isolated". This gives the heartbeat
@@ -1009,9 +1021,10 @@ export async function runHeartbeatOnce(opts: {
// sending the full conversation history (~100K tokens) to the LLM.
// Delivery routing still uses the main session entry (lastChannel, lastTo).
const useIsolatedSession = heartbeat?.isolatedSession === true;
const firstDueCommitment = canHeartbeatDeliverCommitments(heartbeat)
? preflight.dueCommitments[0]
: undefined;
const firstDueCommitment =
canHeartbeatDeliverCommitments(heartbeat) && dueHeartbeatTasks.length === 0
? preflight.dueCommitments[0]
: undefined;
const commitmentDeliveryContext = firstDueCommitment
? {
channel: firstDueCommitment.channel,
@@ -1083,6 +1096,7 @@ export async function runHeartbeatOnce(opts: {
canRelayToUser,
workspaceDir,
startedAt,
dueTasks: dueHeartbeatTasks,
heartbeatFileContent: preflight.heartbeatFileContent,
});
const dueCommitmentIds = hasDueCommitments