From bb397dff02c0534252ae621cb226d86513efa555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 16 Apr 2026 22:53:19 -0400 Subject: [PATCH] refactor(heartbeat): centralize [Heartbeat] prefix as a shared constant P2-36: Multiple subsystems were string-coupled via the magic '[Heartbeat]' prefix. Introduce src/main/orchestration/heartbeat-task.ts as the single source of truth and replace scattered literals in heartbeat dispatch, automation actions, and run completion. P3-52: Collapse 5-deep ternary in automation-actions' wake-trigger selection into lookup helpers (deriveHeartbeatWakeTrigger, deriveEscalationWakeTrigger). --- src/main/automation/automation-actions.ts | 49 ++++++++++--------- .../automation/automation-run-completion.ts | 3 +- src/main/orchestration/heartbeat-task.ts | 15 ++++++ src/main/orchestration/heartbeat.ts | 7 +-- 4 files changed, 48 insertions(+), 26 deletions(-) create mode 100644 src/main/orchestration/heartbeat-task.ts diff --git a/src/main/automation/automation-actions.ts b/src/main/automation/automation-actions.ts index 9bd8f555..b3946f2e 100644 --- a/src/main/automation/automation-actions.ts +++ b/src/main/automation/automation-actions.ts @@ -3,6 +3,29 @@ import type { AutomationAction, DesktopEvent } from "@shared/types"; import { dispatchAgentMessage } from "../message-dispatch"; import { validateTaskCreation } from "../task-quality-gates"; import type { AutomationEventContext } from "./types"; +import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task"; + +// Map trigger type → default wake trigger when automation fires a heartbeat. +const HEARTBEAT_WAKE_TRIGGER_MAP: Record = { + run_completed: "subtask_completed", + run_failed: "report_failed", + hire_approved: "assignment", + agent_idle: "timer", +}; + +function deriveHeartbeatWakeTrigger(triggerType: string, previousStatus: string | null): string { + if (triggerType === "task_status_changed" && previousStatus === "in_review") return "comment"; + return HEARTBEAT_WAKE_TRIGGER_MAP[triggerType] ?? "manual"; +} + +// Map the run/state combination → wake trigger for escalation to manager. +function deriveEscalationWakeTrigger(newStatus: string | null, ruleTrigger: string | null): string { + if (newStatus === "in_review") return "subtask_completed"; + if (newStatus === "blocked") return "report_blocked"; + if (ruleTrigger === "run_completed") return "subtask_completed"; + if (ruleTrigger === "run_failed") return "report_failed"; + return "report_blocked"; +} export interface ActionDeps { db: AppDatabase; @@ -81,7 +104,7 @@ export function executeAutomationAction( const description = (config.description as string) ?? ""; const priority = (config.priority as string) ?? "medium"; const assigneeAgentId = (config.assigneeAgentId as string) ?? null; - const isHeartbeat = title.startsWith("[Heartbeat]"); + const isHeartbeat = isHeartbeatTaskTitle(title); const taskValidation = validateTaskCreation({ title, description }, isHeartbeat); if (!taskValidation.valid) { logger.warn(`[automation] Skipping create_task — validation failed: ${taskValidation.errors.join("; ")}`); @@ -152,16 +175,7 @@ export function executeAutomationAction( } } if (targetAgentId) { - // Derive a descriptive wake trigger from the automation context - const triggerType = rule.trigger ?? ""; - const wakeTrigger = - triggerType === "task_status_changed" && ctx.previousStatus === "in_review" ? "comment" - : triggerType === "run_completed" ? "subtask_completed" - : triggerType === "run_failed" ? "report_failed" - : triggerType === "hire_approved" ? "assignment" - : triggerType === "agent_idle" ? "timer" - : "manual"; - wakeAgentIfPossible(targetAgentId, ctx.companyId, wakeTrigger); + wakeAgentIfPossible(targetAgentId, ctx.companyId, deriveHeartbeatWakeTrigger(rule.trigger ?? "", ctx.previousStatus ?? null)); } break; } @@ -204,22 +218,13 @@ export function executeAutomationAction( // Keep the fallback title when the task no longer exists. } // Heartbeat tasks are internal system tasks — skip escalation messages to avoid flooding channels. - if (taskTitle.startsWith("[Heartbeat]")) { + if (isHeartbeatTaskTitle(taskTitle)) { logger.info(`[automation] Skipping escalation for heartbeat task "${taskTitle}"`); break; } const manager = db.getManagerForAgent(targetAgentId); const escalationBody = (config.body as string) ?? `Task "${taskTitle}" needs your attention. ${agentName} has flagged this as requiring manager intervention.`; - // Pick the most descriptive wake trigger based on the originating event - const wakeTrigger = ctx.newStatus === "in_review" - ? "subtask_completed" - : ctx.newStatus === "blocked" - ? "report_blocked" - : rule.trigger === "run_completed" - ? "subtask_completed" - : rule.trigger === "run_failed" - ? "report_failed" - : "report_blocked"; + const wakeTrigger = deriveEscalationWakeTrigger(ctx.newStatus ?? null, rule.trigger ?? null); if (manager) { const escalationResult = dispatchAgentMessage( { db, emitEvent, publishDomainChanged, wakeAgentIfPossible }, diff --git a/src/main/automation/automation-run-completion.ts b/src/main/automation/automation-run-completion.ts index c5df9cd2..1d66f453 100644 --- a/src/main/automation/automation-run-completion.ts +++ b/src/main/automation/automation-run-completion.ts @@ -3,6 +3,7 @@ import type { RunRecord, SectionId } from "@shared/types"; import type { NotificationOptions } from "../notification-service"; import type { AutomationEventContext, RunFinishedEvent } from "./types"; import { IMPORTANT_DOCUMENT_TYPES } from "./types"; +import { isHeartbeatTaskTitle } from "../orchestration/heartbeat-task"; function localizeNotification(locale: string | null | undefined, en: string, zh: string): string { return locale === "zh" ? zh : en; @@ -290,7 +291,7 @@ export function processRunCompletion( } } - if (isSuccess && task.title.startsWith("[Heartbeat]") && effectiveStatus !== "done") { + if (isSuccess && isHeartbeatTaskTitle(task.title) && effectiveStatus !== "done") { db.advanceTaskStatus(task.id, "done", "Heartbeat check completed."); handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done"); } diff --git a/src/main/orchestration/heartbeat-task.ts b/src/main/orchestration/heartbeat-task.ts new file mode 100644 index 00000000..1e5e3d2e --- /dev/null +++ b/src/main/orchestration/heartbeat-task.ts @@ -0,0 +1,15 @@ +/** + * Heartbeat task title conventions. + * + * Heartbeat tasks are synthesized by the orchestrator when an agent wakes with + * no actionable work; they carry the `[Heartbeat]` title prefix so the rest of + * the system can detect them without a dedicated `kind` column. + * + * Centralizing the prefix here prevents drift between the writer (heartbeat + * dispatch) and readers (automation, escalation, route filters). + */ +export const HEARTBEAT_TASK_TITLE_PREFIX = "[Heartbeat]"; + +export function isHeartbeatTaskTitle(title: string | null | undefined): boolean { + return !!title && title.startsWith(HEARTBEAT_TASK_TITLE_PREFIX); +} diff --git a/src/main/orchestration/heartbeat.ts b/src/main/orchestration/heartbeat.ts index 5357f050..831e9bdc 100644 --- a/src/main/orchestration/heartbeat.ts +++ b/src/main/orchestration/heartbeat.ts @@ -3,6 +3,7 @@ import type { AppDatabase } from "../database"; import type { AgentRecord, ConnectorRecord } from "@shared/types"; import type { OrchestrationDependencies } from "./types"; import type { RunDispatchParams } from "./turn-context"; +import { HEARTBEAT_TASK_TITLE_PREFIX } from "./heartbeat-task"; export function createHeartbeat( deps: OrchestrationDependencies, @@ -151,10 +152,10 @@ export function createHeartbeat( } const taskTitle = isCeo - ? `[Heartbeat] ${agent.name} — Strategic planning & task delegation` + ? `${HEARTBEAT_TASK_TITLE_PREFIX} ${agent.name} — Strategic planning & task delegation` : isManager - ? `[Heartbeat] ${agent.name} — Review team & delegate work` - : `[Heartbeat] ${agent.name} — Check for work`; + ? `${HEARTBEAT_TASK_TITLE_PREFIX} ${agent.name} — Review team & delegate work` + : `${HEARTBEAT_TASK_TITLE_PREFIX} ${agent.name} — Check for work`; const taskDescription = isCeo ? "Review active company goals. Break each goal into projects and actionable tasks. Assign tasks to department leads and team members. Create tasks using the API. This is your primary responsibility as CEO — ensure every goal has a clear execution plan with assigned owners." : isManager