From d3e108ec18c6f4d5bec996bded4acc2583412c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 28 Apr 2026 23:21:25 -0400 Subject: [PATCH] fix(scheduler): respect heartbeat eligibility for task cadence --- src/main/orchestration/scheduler.ts | 16 ++++++++----- tests/unit/scheduler-heartbeat.test.ts | 32 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/main/orchestration/scheduler.ts b/src/main/orchestration/scheduler.ts index 458828b5..d10a9a4c 100644 --- a/src/main/orchestration/scheduler.ts +++ b/src/main/orchestration/scheduler.ts @@ -223,11 +223,12 @@ export function createScheduler( if (hasActiveRun({ agentId: ceo.id, companyId: company.id })) return; const lastHeartbeat = ceo.lastHeartbeatAt ? new Date(ceo.lastHeartbeatAt).getTime() : 0; - const lastAttempt = bootstrapAttemptByAgent.get(ceo.id) ?? 0; + const attemptKey = agentAttemptKey(company.id, ceo.id); + const lastAttempt = bootstrapAttemptByAgent.get(attemptKey) ?? 0; const bootstrapCooldown = 120_000; if (now - Math.max(lastHeartbeat, lastAttempt) <= bootstrapCooldown) return; - bootstrapAttemptByAgent.set(ceo.id, now); + bootstrapAttemptByAgent.set(attemptKey, now); logger.info(`[scheduler] Company ${company.name} has ${goalCount} goal(s) and no advancing tasks; bootstrapping CEO ${ceo.name}`); wakeAgentIfPossible(ceo.id, company.id, "bootstrap"); } @@ -243,6 +244,8 @@ export function createScheduler( const wokenAgentIds = new Set(); const pendingWakeupAgents = db.automation.wakeup.listPendingWakeupAgents(companyId); for (const { agentId } of pendingWakeupAgents) { + // Queued wakeups are explicit durable events; they bypass the periodic + // heartbeat_enabled scan and let wakeAgentIfPossible own re-enabling. if (hasActiveRun({ agentId, companyId })) continue; if (deps.checkpointEngine?.shouldPauseHeartbeat(companyId, agentId)) continue; @@ -264,21 +267,22 @@ export function createScheduler( } function runAssignedTaskWakeCadence(companyId: string, now: number, wokenAgentIds: Set) { - const activeAgents = db.core.agents.listActiveAgentsForCompany(companyId); + const activeAgents = db.core.agents.listHeartbeatEligibleAgents(companyId); for (const activeAgent of activeAgents) { if (wokenAgentIds.has(activeAgent.id)) continue; if (hasActiveRun({ agentId: activeAgent.id, companyId })) continue; if (deps.checkpointEngine?.shouldPauseHeartbeat(companyId, activeAgent.id)) continue; const lastHeartbeat = activeAgent.lastHeartbeatAt ? new Date(activeAgent.lastHeartbeatAt).getTime() : 0; - const lastAttempt = taskWakeAttemptByAgent.get(activeAgent.id) ?? 0; - const intervalMs = (activeAgent.heartbeatIntervalSec || 120) * 1000; + const attemptKey = agentAttemptKey(companyId, activeAgent.id); + const lastAttempt = taskWakeAttemptByAgent.get(attemptKey) ?? 0; + const intervalMs = (activeAgent.heartbeatIntervalSec ?? 120) * 1000; if (now - Math.max(lastHeartbeat, lastAttempt) < intervalMs) continue; const actionableTasks = db.core.tasks.listAgentTasks(activeAgent.id, companyId, ["todo"]); if (!actionableTasks.some((task) => !task.activeRunId)) continue; - taskWakeAttemptByAgent.set(activeAgent.id, now); + taskWakeAttemptByAgent.set(attemptKey, now); wakeAgentIfPossible(activeAgent.id, companyId, "timer"); } } diff --git a/tests/unit/scheduler-heartbeat.test.ts b/tests/unit/scheduler-heartbeat.test.ts index 1b56ca40..82d91ecf 100644 --- a/tests/unit/scheduler-heartbeat.test.ts +++ b/tests/unit/scheduler-heartbeat.test.ts @@ -199,6 +199,38 @@ describe("unified scheduler heartbeat cadence", () => { expect(wakeAgentIfPossible).toHaveBeenCalledWith(agentId, companyId, "timer"); }); + it("does not wake assigned work for an agent with heartbeat disabled", () => { + const companyId = createCompany(db); + createConnector(db); + const workspaceId = createWorkspace(db, companyId, tempDir); + const agentId = createAgent(db, companyId, workspaceId); + db.core.agents.setHeartbeat(agentId, false, 1); + db.core.tasks.saveTask({ + companyId, + title: "Paused operating work", + description: "Manual heartbeat disable should pause periodic recovery.", + assigneeAgentId: agentId, + workspaceId, + priority: "high", + status: "todo", + }); + const wakeAgentIfPossible = vi.fn(); + const scheduler = createScheduler( + createDeps(db), + () => false, + wakeAgentIfPossible, + ); + + try { + scheduler.startAllSchedulers(); + vi.advanceTimersByTime(60_000); + } finally { + scheduler.stopAllSchedulers(); + } + + expect(wakeAgentIfPossible).not.toHaveBeenCalled(); + }); + it("wakes an agent with a queued wakeup request on heartbeat cadence", () => { const companyId = createCompany(db); createConnector(db);