diff --git a/src/infra/heartbeat-runner.commitments.test.ts b/src/infra/heartbeat-runner.commitments.test.ts index 8d39b9cac91..5a1c61dc333 100644 --- a/src/infra/heartbeat-runner.commitments.test.ts +++ b/src/infra/heartbeat-runner.commitments.test.ts @@ -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 }) => { diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 6be7e048dfe..75c09c0ef47 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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, + startedAt: number, +): HeartbeatTask[] { + const tasks = preflight.tasks; + if (!tasks || tasks.length === 0) { + return []; + } + return tasks.filter((task) => + isTaskDue( + (preflight.session.entry?.heartbeatTaskState as Record)?.[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)?.[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