feat(FN-4394): complete Step 4 — wire heartbeat scope resolver into runs

Fusion-Task-Id: FN-4394
Fusion-Task-Lineage: 1e7ad660-2cd2-40ba-b7b8-7ef552c6fa13
This commit is contained in:
Fusion
2026-05-14 02:55:15 -07:00
committed by gsxdsm
parent f276610bd5
commit 7d7f23c1a5
4 changed files with 100 additions and 4 deletions

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatScopeDisciplineMode } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
resolveAgentMemoryInclusionMode,

View File

@@ -7,6 +7,7 @@ import {
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
HEARTBEAT_PROCEDURE,
HEARTBEAT_PROCEDURE_OFF,
HEARTBEAT_NO_TASK_PROCEDURE,
getAgentSoulWords,
} from "../agent-heartbeat.js";
@@ -1146,6 +1147,72 @@ describe("executeHeartbeat", () => {
expect(savedRun?.heartbeatProcedureSource).toBe("default");
});
it("uses lite task-scoped procedure when project heartbeatScopeDiscipline is lite", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
mockTaskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({ heartbeatScopeDiscipline: "lite" }),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const persistedRun = vi.mocked(store.saveRun).mock.calls
.map(([arg]) => arg)
.find((arg): arg is AgentHeartbeatRun => typeof arg.executionPrompt === "string");
expect(persistedRun?.executionPrompt).toContain("Assignment review");
expect(persistedRun?.executionPrompt).toContain("Classify scope before acting");
expect(persistedRun?.executionPrompt).not.toContain("Per-tick self-check");
expect(persistedRun?.contextSnapshot?.heartbeatScopeDiscipline).toBe("lite");
});
it("uses agent runtimeConfig heartbeatScopeDiscipline over project default", async () => {
const store = createStoreWithAgentForExec({
runtimeConfig: { heartbeatScopeDiscipline: "strict" },
taskId: "FN-001",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
mockTaskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({ heartbeatScopeDiscipline: "lite" }),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const persistedRun = vi.mocked(store.saveRun).mock.calls
.map(([arg]) => arg)
.find((arg): arg is AgentHeartbeatRun => typeof arg.executionPrompt === "string");
expect(persistedRun?.executionPrompt).toContain("Per-tick self-check");
expect(persistedRun?.executionPrompt).toContain("Classify the bound task");
expect(persistedRun?.executionPrompt).not.toContain("Assignment review");
expect(persistedRun?.contextSnapshot?.heartbeatScopeDiscipline).toBe("strict");
});
it("off mode omits scope-classification guidance", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
mockTaskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({ heartbeatScopeDiscipline: "off" }),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE_OFF);
expect(executionPrompt).not.toContain("Classify scope before acting");
expect(executionPrompt).not.toContain("Classify the bound task");
const savedRun = await store.getRunDetail("agent-001", result.id);
expect(savedRun?.contextSnapshot?.heartbeatScopeDiscipline).toBe("off");
});
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const mockSession = createMockAgentSession();
@@ -2775,6 +2842,7 @@ describe("executeHeartbeat", () => {
taskId: "FN-001",
triggeringCommentIds: ["comment-1"],
triggeringCommentType: "task",
heartbeatScopeDiscipline: "strict",
});
});

View File

@@ -22,6 +22,10 @@ describe("resolveHeartbeatScopeDisciplineMode", () => {
expect(resolveHeartbeatScopeDisciplineMode(project("invalid" as never), agent(undefined))).toBe("strict");
});
it("falls through invalid agent and invalid project modes to strict default", () => {
expect(resolveHeartbeatScopeDisciplineMode(project("invalid" as never), agent("invalid"))).toBe("strict");
});
it("defaults to strict when unset", () => {
expect(resolveHeartbeatScopeDisciplineMode(undefined, undefined)).toBe("strict");
});

View File

@@ -30,6 +30,7 @@ import {
buildPluginPromptSection,
resolveAgentHeartbeatProcedure,
} from "./agent-instructions.js";
import { resolveHeartbeatScopeDisciplineMode, selectHeartbeatProcedure } from "./heartbeat-procedure-resolver.js";
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
import { heartbeatLog, formatError } from "./logger.js";
import { acquireTaskWorktree } from "./worktree-acquisition.js";
@@ -2375,9 +2376,23 @@ export class HeartbeatMonitor {
`Agent ${agentId} no-task heartbeat bypassed configured heartbeatProcedurePath and used HEARTBEAT_NO_TASK_PROCEDURE to keep prompt guidance aligned with ambient tools`,
);
}
const heartbeatScopeDiscipline = resolveHeartbeatScopeDisciplineMode(heartbeatModelSettings, agent);
const resolvedProcedureTemplate = selectHeartbeatProcedure(heartbeatScopeDiscipline, isNoTaskRun, {
task: {
strict: HEARTBEAT_PROCEDURE_STRICT,
lite: HEARTBEAT_PROCEDURE_LITE,
off: HEARTBEAT_PROCEDURE_OFF,
},
noTask: {
strict: HEARTBEAT_NO_TASK_PROCEDURE_STRICT,
lite: HEARTBEAT_NO_TASK_PROCEDURE_LITE,
off: HEARTBEAT_NO_TASK_PROCEDURE_OFF,
},
});
const heartbeatProcedureText = shouldOverrideCustomProcedureForNoTaskRun
? HEARTBEAT_NO_TASK_PROCEDURE
: (customProcedure ?? (isNoTaskRun ? HEARTBEAT_NO_TASK_PROCEDURE : HEARTBEAT_PROCEDURE));
? resolvedProcedureTemplate
: (customProcedure ?? resolvedProcedureTemplate);
// Precedence: heartbeatProcedurePath (custom file) > resolved heartbeatScopeDiscipline template > strict default.
const heartbeatProcedureSource = shouldOverrideCustomProcedureForNoTaskRun
? "default-no-task-override"
: (customProcedure ? "custom" : "default");
@@ -2581,10 +2596,19 @@ export class HeartbeatMonitor {
systemPrompt: truncatePrompt(systemPromptFinal, 100_000),
executionPrompt: truncatePrompt(executionPrompt, 100_000),
heartbeatProcedureSource,
contextSnapshot: {
...(run.contextSnapshot ?? {}),
heartbeatScopeDiscipline,
},
};
await this.store.saveRun(runWithPrompts);
// Update local run reference so completeRun merges correctly
Object.assign(run, { systemPrompt: runWithPrompts.systemPrompt, executionPrompt: runWithPrompts.executionPrompt, heartbeatProcedureSource: runWithPrompts.heartbeatProcedureSource });
Object.assign(run, {
systemPrompt: runWithPrompts.systemPrompt,
executionPrompt: runWithPrompts.executionPrompt,
heartbeatProcedureSource: runWithPrompts.heartbeatProcedureSource,
contextSnapshot: runWithPrompts.contextSnapshot,
});
} catch (promptPersistErr) {
heartbeatLog.warn(`Failed to persist prompts for ${agentId}/${run.id}: ${promptPersistErr instanceof Error ? promptPersistErr.message : String(promptPersistErr)}`);
}