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).
This commit is contained in:
1 parent
4838750009
commit
bb397dff02
4 files changed
+48
-26
No files matched your search
@@ -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<string, string> = {
|
||||
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 },
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user