FN-7963: add plannerHeartbeatPatrolEnabled to gate idle heartbeat task creation

Add a workflow setting that disables idle/no-task heartbeat proactive task creation without turning off planner overseer stuck-task recovery.

- Declare plannerHeartbeatPatrolEnabled (default true) in BUILTIN_OVERSIGHT_SETTINGS
- Resolve the flag via resolveEffectivePlannerHeartbeatPatrolEnabled and wire it into agent-heartbeat/triage prompts
- Render patrol-off instruction when disabled; keep FN-7962 outage backoff lines when patrol stays enabled
- Cover setting defaults, prompt builders, and heartbeat executor paths with tests
- Document the setting in settings-reference and add a changeset

Files changed:
 .changeset/fn-7963-planner-heartbeat-patrol.md     |   7 ++
 docs/settings-reference.md                         |  11 +-
 packages/core/src/__tests__/agent-prompts.test.ts  |  29 +++++
 .../builtin-workflow-settings-triage.test.ts       |  21 ++++
 .../plannerHeartbeatPatrolEnabled-default.test.ts  |  64 ++++++++++
 packages/core/src/agent-prompts.ts                 |  55 +++++++--
 packages/core/src/builtin-workflow-settings.ts     |  14 +++
 packages/core/src/index.gate.ts                    |   5 +
 packages/core/src/index.ts                         |   5 +
 packages/core/src/workflow-settings-resolver.ts    |  15 ++-
 .../src/__tests__/heartbeat-executor.test.ts       |  59 ++++++++-
 packages/engine/src/agent-heartbeat.ts             | 135 +++++++++++++++++++--
 packages/engine/src/triage.ts                      |   8 +-
 13 files changed, 402 insertions(+), 26 deletions(-)

Fusion-Task-Id: FN-7963

Fusion-Task-Lineage: c5e7a382-52c1-4cc1-8b21-aba7dc7d2b97

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 12:27:15 -07:00
parent b2977d1a7a
commit de25e32eac
13 changed files with 402 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Add a workflow setting to disable idle heartbeat task patrol.
category: feature
dev: Adds plannerHeartbeatPatrolEnabled for no-task heartbeat and triage prompt gating.

View File

@@ -295,8 +295,9 @@ Actions. It has two tabs:
automatic revisions, enter a non-negative integer to cap attempts, or enter `0`
to disable automatic revision for that path. `plannerOversightLevel` is the
workflow-native planner oversight mode and accepts `off`, `observe`, `steer`,
or `autonomous` (default). Edits batch and commit through a single **Save** in
the Values tab.
or `autonomous` (default). `plannerHeartbeatPatrolEnabled` controls idle/no-task
heartbeat patrol task creation separately and defaults to `true`. Edits batch
and commit through a single **Save** in the Values tab.
**How values resolve.** The engine resolves *effective settings* per task as
`stored value ?? declaration default`. The task-detail Workflow, Chat, and Agent
@@ -335,7 +336,7 @@ These groups moved out of project settings and into workflow settings (built-in
|---|---|
| **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` |
| **Review / approval** | Workflow values: `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, `planReviewMaxRevisions`, `codeReviewMaxRevisions`; project override: `planApprovalMode` |
| **Planner oversight** | `plannerOversightLevel` (workflow-native; values: `off`, `observe`, `steer`, `autonomous`); `plannerOversightNotificationLevel` (workflow-native; values: `silent`, `errors`, `important`, `all`); `plannerOverseerExecutorStuckAfterMs` (workflow-native; number, default `7200000` = 2h); `plannerOverseerAdvisorEnabled` (boolean, **default false**); `plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId` (session-advisor model; both required when enabled) |
| **Planner oversight** | `plannerOversightLevel` (workflow-native; values: `off`, `observe`, `steer`, `autonomous`); `plannerOversightNotificationLevel` (workflow-native; values: `silent`, `errors`, `important`, `all`); `plannerOverseerExecutorStuckAfterMs` (workflow-native; number, default `7200000` = 2h); `plannerOverseerAdvisorEnabled` (boolean, **default false**); `plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId` (session-advisor model; both required when enabled); `plannerHeartbeatPatrolEnabled` (workflow-native; boolean, default `true`, gates idle/no-task heartbeat patrol task creation) |
| **Per-phase model lanes** | `executionProvider`/`executionModelId` + `executionThinkingLevel`, `planningProvider`/`planningModelId` + `planningThinkingLevel` (+ fallbacks), `validatorProvider`/`validatorModelId` + `validatorThinkingLevel` (+ fallbacks). Thinking values accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset inherits. |
### Workflow-native triage policy settings
@@ -346,6 +347,9 @@ Triage workflow defaults are policy inputs, not permission to reroute tasks auto
FNXC:TriagePolicy 2026-07-04-00:00:
`triageProactiveSubtaskSplittingEnabled` is workflow/project-scoped so operators can disable automatic oversized-task splitting without disabling explicit per-task `breakIntoSubtasks: true` requests.
FNXC:HeartbeatPatrol 2026-07-15-03:05:
`plannerHeartbeatPatrolEnabled` is documented beside planner oversight because operators need a separate workflow-native switch for idle/no-task task-creation patrol. It must not be described as disabling stuck-task observation, steering, or recovery for tasks already in flight.
-->
The built-in workflows also declare triage/spec policy settings that were **not** moved from project settings. They are workflow-native declarations: they never lived in `DEFAULT_PROJECT_SETTINGS`, are not `MOVED_SETTINGS_KEYS`, and resolve only through the workflow effective-settings path.
@@ -375,6 +379,7 @@ The built-in workflows also declare triage/spec policy settings that were **not*
| `plannerOverseerAdvisorProvider` | `""` | Session-advisor model provider (OMP advisor parity). Used only when `plannerOverseerAdvisorEnabled` is true. Must be set together with `plannerOverseerAdvisorModelId`. |
| `plannerOverseerAdvisorModelId` | `""` | Session-advisor model id. Used only when `plannerOverseerAdvisorEnabled` is true. When enabled and both model fields are set, the advisor reviews executor agent-log deltas and may inject `[session-advisor]` steering comments at `steer`/`autonomous` (observe = log only). Discover project review priorities via `OVERSEER.md` / `WATCHDOG.md`. See `docs/architecture.md` → "Planner overseer session advisor". |
| `plannerOverseerExecutorStuckAfterMs` | `7200000` (2h) | Workflow-native executor-stage stall threshold (FN-7743). Milliseconds of executor-stage inactivity — no execution activity since the task's last column move/update (`columnMovedAt ?? updatedAt`) — before a non-paused `in-progress` task is reported `signal: "stuck"` instead of `"progressing"`, feeding the existing `decidePlannerRecovery` → bounded `inject_guidance` recovery path at the `autonomous` oversight level (no effect at `off`/`observe`/`steer`). Fixes the class of bug where a genuinely hung/idle executor (dead session, silent agent) was indistinguishable from a healthy one and was never nudged, retried, or escalated. A missing/malformed activity timestamp degrades to `"progressing"` (fail-safe — never fabricates a stall), and a user-paused/approval-blocked/`autoMerge:false` task is still fully withheld from any autonomous action regardless of this threshold. Resolves through the generic `resolveEffectiveSettings` default path alongside `plannerOversightLevel`. See `docs/architecture.md` → "Executor-stage stall detection (FN-7743)". |
| `plannerHeartbeatPatrolEnabled` | `true` | Workflow-native idle-heartbeat patrol switch (FN-7963). `true` preserves the existing no-task heartbeat/triage guidance that lets idle agents scan for gaps and create focused follow-up tasks. Set to `false` to remove proactive patrol task-creation guidance from idle/no-task heartbeat prompts; agents should then handle assigned work, direct messages, explicit operator requests, and safe read-only/logging coordination instead of opening new patrol tasks. This is separate from `plannerOversightLevel`: disabling heartbeat patrol does **not** disable stuck-task observation, steering, retry, or targeted-fix recovery for tasks already in flight. No-task heartbeats resolve this value from the project default workflow, falling back to `builtin:coding` when no default workflow is set. |
When `triageProactiveSubtaskSplittingEnabled` is `true` (the default), triage may proactively replace a large task with 2-5 child tasks when the size, step-count, package breadth, file-scope, or remediation-batch signals justify the coordination overhead. When it is `false`, those automatic oversized-task signals are advisory only for writing a realistic single-task spec; triage must not split solely because the task is large. The per-task `breakIntoSubtasks: true` flag is separate and remains mandatory: if a user explicitly asks for subtask breakdown, triage still evaluates and creates child tasks when the work is meaningfully decomposable.

View File

@@ -8,6 +8,7 @@ import {
getAvailableTemplates,
getTemplatesForRole,
FUSION_RUNTIME_SELF_AWARENESS,
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
} from "../agent-prompts.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { BUILTIN_SEAM_PROMPTS, builtinSeamPrompt } from "../builtin-workflow-prompts.js";
@@ -33,6 +34,34 @@ describe("resolveAgentPrompt", () => {
expect(result).toContain("task specification agent");
});
it("renders triage heartbeat patrol guidance by default and when explicitly enabled", () => {
const defaultPrompt = resolveAgentPrompt("triage");
const enabledPrompt = resolveAgentPrompt("triage", undefined, { plannerHeartbeatPatrolEnabled: true });
for (const prompt of [defaultPrompt, enabledPrompt]) {
expect(prompt).toContain("Patrol for vague requests");
expect(prompt).toContain("review follow-ups that should become new tasks");
expect(prompt).not.toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
}
});
it("renders no-patrol triage heartbeat instructions when disabled", () => {
const result = resolveAgentPrompt("triage", undefined, { plannerHeartbeatPatrolEnabled: false });
expect(result).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(result).not.toContain("Patrol for vague requests");
expect(result).not.toContain("review follow-ups that should become new tasks");
});
it("renders no-patrol concise triage heartbeat instructions when disabled", () => {
const result = resolveAgentPrompt("triage", {
roleAssignments: { triage: "concise-triage" },
}, { plannerHeartbeatPatrolEnabled: false });
expect(result).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(result).not.toContain("turn it into short, actionable task specs or follow-up tickets");
});
it("returns the correct built-in prompt for reviewer when no config provided", () => {
const result = resolveAgentPrompt("reviewer");
expect(result).toBeTruthy();

View File

@@ -93,6 +93,7 @@ describe("workflow-native built-in workflow settings", () => {
"plannerOverseerAdvisorEnabled",
"plannerOverseerAdvisorProvider",
"plannerOverseerAdvisorModelId",
"plannerHeartbeatPatrolEnabled",
]);
// FNXC:PlannerOversight 2026-07-14-12:00: LLM session advisor must default OFF.
expect(BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "plannerOverseerAdvisorEnabled")).toMatchObject({
@@ -174,6 +175,26 @@ describe("workflow-native built-in workflow settings", () => {
movedKeyIds.has("plannerOverseerExecutorStuckAfterMs"),
"plannerOverseerExecutorStuckAfterMs should not be in MOVED_SETTINGS_KEYS",
).toBe(false);
const heartbeatPatrol = BUILTIN_OVERSIGHT_SETTINGS[6];
expect(heartbeatPatrol).toMatchObject({
id: "plannerHeartbeatPatrolEnabled",
type: "boolean",
default: true,
});
expect(heartbeatPatrol.description).toMatch(/idle\/no-task heartbeat proactive patrol/i);
expect(
fullIds.has("plannerHeartbeatPatrolEnabled"),
"plannerHeartbeatPatrolEnabled should be in the full built-in catalog",
).toBe(true);
expect(
movedIds.has("plannerHeartbeatPatrolEnabled"),
"plannerHeartbeatPatrolEnabled should not be in the moved-key catalog",
).toBe(false);
expect(
movedKeyIds.has("plannerHeartbeatPatrolEnabled"),
"plannerHeartbeatPatrolEnabled should not be in MOVED_SETTINGS_KEYS",
).toBe(false);
});
it("renders placeholders from resolved settings and rejects dangling tokens", () => {

View File

@@ -0,0 +1,64 @@
/*
* FNXC:HeartbeatPatrol 2026-07-14-23:38:
* Idle no-task heartbeat patrol defaults on for compatibility, but an explicit workflow false must survive effective-settings resolution so operators can disable proactive task creation without touching planner oversight recovery.
*/
import { describe, expect, it, vi } from "vitest";
import { BUILTIN_OVERSIGHT_SETTINGS, BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import {
resolveEffectivePlannerHeartbeatPatrolEnabled,
resolveEffectiveSettingsById,
type WorkflowSettingsResolverStore,
} from "../workflow-settings-resolver.js";
const PROJECT = "proj-1";
function makeStore(values?: Record<string, unknown>): WorkflowSettingsResolverStore {
return {
getTaskWorkflowSelection: vi.fn(() => undefined),
getWorkflowDefinition: vi.fn(async () => undefined),
getWorkflowSettingValues: vi.fn(() => values ?? {}),
getWorkflowSettingsProjectId: vi.fn(() => PROJECT),
};
}
describe("plannerHeartbeatPatrolEnabled default (FN-7963)", () => {
it("declares the workflow-native boolean default in the full catalog", () => {
const decl = BUILTIN_OVERSIGHT_SETTINGS.find((setting) => setting.id === "plannerHeartbeatPatrolEnabled");
expect(decl).toMatchObject({
type: "boolean",
default: true,
});
expect(BUILTIN_WORKFLOW_SETTINGS.some((setting) => setting.id === "plannerHeartbeatPatrolEnabled")).toBe(true);
});
it("resolves unset built-in workflow patrol to true", async () => {
const eff = await resolveEffectiveSettingsById(makeStore({}), "builtin:coding", PROJECT);
expect(eff.plannerHeartbeatPatrolEnabled).toBe(true);
expect(resolveEffectivePlannerHeartbeatPatrolEnabled(eff)).toBe(true);
});
it("honors explicit stored false", async () => {
const eff = await resolveEffectiveSettingsById(
makeStore({ plannerHeartbeatPatrolEnabled: false }),
"builtin:coding",
PROJECT,
);
expect(eff.plannerHeartbeatPatrolEnabled).toBe(false);
expect(resolveEffectivePlannerHeartbeatPatrolEnabled(eff)).toBe(false);
});
it("honors explicit stored true", async () => {
const eff = await resolveEffectiveSettingsById(
makeStore({ plannerHeartbeatPatrolEnabled: true }),
"builtin:coding",
PROJECT,
);
expect(eff.plannerHeartbeatPatrolEnabled).toBe(true);
expect(resolveEffectivePlannerHeartbeatPatrolEnabled(eff)).toBe(true);
});
});

View File

@@ -1224,19 +1224,34 @@ Treat each heartbeat as a short autonomous execution cycle.
- If no task is assigned: execute your standing instructions. Review unread messages, scan for blocked or failing engineering work, create narrowly scoped follow-up tasks, and capture durable implementation notes other agents will need later.
- Do not idle simply because no task is linked. Use heartbeat time to reduce engineering risk, unblock work, and keep execution moving in small, concrete increments.`;
export const TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION = "If no task is assigned: do not create new tasks during idle/no-task heartbeats. Only handle assigned work, direct messages, explicit operator requests, and safe read-only/logging coordination.";
/*
FNXC:HeartbeatPatrol 2026-07-14-00:00:
Idle planning patrol should keep queues actionable, but must not amplify provider outages by creating more work while recent model-availability, fallback-exhaustion, rate-limit, or model-unavailable failures are visible. Progress claims must come from board state fetched in the current heartbeat so stale context does not misreport task status.
FNXC:HeartbeatPatrol 2026-07-14-23:41:
Triage heartbeat patrol is prompt guidance, not planner-overseer recovery. Render the no-task line from the workflow setting so disabling patrol stops idle task-creation nudges while leaving assigned-task triage behavior unchanged.
*/
const TRIAGE_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior
export function buildTriageHeartbeatGuidance(options: { plannerHeartbeatPatrolEnabled?: boolean } = {}): string {
const noTaskLine = options.plannerHeartbeatPatrolEnabled === false
? TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION
: "If no task is assigned: execute your planning instructions. Patrol for vague requests, blocked tasks that need better specification, review follow-ups that should become new tasks, and dependency gaps that are slowing executors down.";
const patrolSafetyLines = options.plannerHeartbeatPatrolEnabled === false
? ""
: `
- Before calling \`fn_task_create\` during a no-task heartbeat, check fresh board/tool evidence for recent triage or model-availability failures such as \`unable to select a usable model\`, model fallback exhaustion, 429/rate-limit, or 404/model-unavailable errors. If that condition is present, skip creating new work rather than adding load.
- Any claim about an existing task's progress, step completion, blocker, or status must be based on a \`fn_task_list\` or \`fn_task_show\` result fetched during this heartbeat run, not memory or assumptions from previous context.`;
return `## Heartbeat Run Behavior
Use heartbeat runs to keep the planning pipeline healthy.
- If a task is assigned: turn the rough request into a complete, execution-ready PROMPT.md with clear scope, steps, dependencies, and verification criteria.
- If no task is assigned: execute your planning instructions. Patrol for vague requests, blocked tasks that need better specification, review follow-ups that should become new tasks, and dependency gaps that are slowing executors down.
- Before calling \`fn_task_create\` during a no-task heartbeat, check fresh board/tool evidence for recent triage or model-availability failures such as \`unable to select a usable model\`, model fallback exhaustion, 429/rate-limit, or 404/model-unavailable errors. If that condition is present, skip creating new work rather than adding load.
- Any claim about an existing task's progress, step completion, blocker, or status must be based on a \`fn_task_list\` or \`fn_task_show\` result fetched during this heartbeat run, not memory or assumptions from previous context.
- ${noTaskLine}${patrolSafetyLines}
- Favor ambiguity reduction over busywork. Every heartbeat should leave the queue more actionable than you found it.`;
}
const TRIAGE_HEARTBEAT_GUIDANCE = buildTriageHeartbeatGuidance();
const REVIEWER_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior
@@ -1270,15 +1285,25 @@ Use heartbeat runs to enforce a high review bar.
- If no task is assigned: execute your review instructions. Look for merges that feel under-reviewed, risky diffs that deserve another pass, and follow-up work needed before code should land.
- Bias toward precise findings and explicit risk articulation. A quiet heartbeat should mean the code is genuinely clean, not that you stopped looking.`;
const CONCISE_TRIAGE_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior
export function buildConciseTriageHeartbeatGuidance(options: { plannerHeartbeatPatrolEnabled?: boolean } = {}): string {
const noTaskLine = options.plannerHeartbeatPatrolEnabled === false
? TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION
: "If no task is assigned: execute your planning instructions, scan for underspecified or blocked work, and turn it into short, actionable task specs or follow-up tickets.";
const patrolSafetyLines = options.plannerHeartbeatPatrolEnabled === false
? ""
: `
- Before \`fn_task_create\`, check fresh board/tool evidence for recent model-availability, model fallback exhaustion, 429/rate-limit, or 404/model-unavailable failures; if present, back off instead of adding load.
- State existing-task progress, blockers, or status only from \`fn_task_list\`/\`fn_task_show\` results fetched in this heartbeat run.`;
return `## Heartbeat Run Behavior
Keep heartbeat output lean and useful.
- If a task is assigned: produce the minimum complete PROMPT.md needed for an executor to act safely.
- If no task is assigned: execute your planning instructions, scan for underspecified or blocked work, and turn it into short, actionable task specs or follow-up tickets.
- Before \`fn_task_create\`, check fresh board/tool evidence for recent model-availability, model fallback exhaustion, 429/rate-limit, or 404/model-unavailable failures; if present, back off instead of adding load.
- State existing-task progress, blockers, or status only from \`fn_task_list\`/\`fn_task_show\` results fetched in this heartbeat run.
- ${noTaskLine}${patrolSafetyLines}
- Prefer crisp decisions, clear file scope, and concrete verification steps over narrative detail.`;
}
const CONCISE_TRIAGE_HEARTBEAT_GUIDANCE = buildConciseTriageHeartbeatGuidance();
// ---------------------------------------------------------------------------
// Built-in templates array
@@ -1368,9 +1393,14 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [
* @throws {Error} If the assigned template ID does not exist in either
* custom or built-in templates.
*/
export interface ResolveAgentPromptOptions {
plannerHeartbeatPatrolEnabled?: boolean;
}
export function resolveAgentPrompt(
role: AgentCapability,
config?: AgentPromptsConfig,
options: ResolveAgentPromptOptions = {},
): string {
const assignedId = config?.roleAssignments?.[role];
@@ -1388,11 +1418,20 @@ export function resolveAgentPrompt(
);
}
if (role === "triage" && template.builtIn && template.id === "default-triage") {
return `${TRIAGE_PROMPT_TEXT}\n\n${buildTriageHeartbeatGuidance(options)}`;
}
if (role === "triage" && template.builtIn && template.id === "concise-triage") {
return `${CONCISE_TRIAGE_PROMPT_TEXT}\n\n${buildConciseTriageHeartbeatGuidance(options)}`;
}
return template.prompt;
}
// Fall back to built-in default for the role
const builtIn = BUILTIN_AGENT_PROMPTS.find((t) => t.role === role && t.id === `default-${role}`);
if (role === "triage" && builtIn?.id === "default-triage") {
return `${TRIAGE_PROMPT_TEXT}\n\n${buildTriageHeartbeatGuidance(options)}`;
}
return builtIn?.prompt ?? "";
}

View File

@@ -503,6 +503,8 @@ export const BUILTIN_REVIEW_REVISION_SETTINGS: WorkflowSettingDefinition[] = [
*/
export const DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS = 2 * 60 * 60 * 1000;
export const PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID = "plannerHeartbeatPatrolEnabled";
export const BUILTIN_OVERSIGHT_SETTINGS: WorkflowSettingDefinition[] = [
{
id: "plannerOversightLevel",
@@ -575,6 +577,18 @@ export const BUILTIN_OVERSIGHT_SETTINGS: WorkflowSettingDefinition[] = [
description:
"Model id for the planner overseer session advisor. Used only when Session advisor (LLM) is enabled. Must be set together with Session advisor model provider.",
},
/*
* FNXC:HeartbeatPatrol 2026-07-14-23:35:
* Idle no-task heartbeat patrol creates net-new work while plannerOversightLevel recovers tasks already in flight. Keep patrol as its own workflow setting so operators can reduce autonomous task creation volume without disabling stuck-task observation, steering, or recovery.
*/
{
id: PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID,
name: "Planner heartbeat patrol enabled",
type: "boolean",
default: true,
description:
"Enable idle/no-task heartbeat proactive patrol guidance that encourages agents to create or delegate new follow-up tasks. Disable to keep idle agents to assigned work, direct messages, explicit operator requests, and safe read-only/logging coordination without disabling planner overseer stuck-task recovery.",
},
];
export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [

View File

@@ -174,6 +174,9 @@ export * from "./shared-mesh-state.js";
export {
BUILTIN_AGENT_PROMPTS,
resolveAgentPrompt,
buildTriageHeartbeatGuidance,
buildConciseTriageHeartbeatGuidance,
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
getAvailableTemplates,
getTemplatesForRole,
} from "./agent-prompts.js";
@@ -267,6 +270,7 @@ export {
BUILTIN_TRIAGE_POLICY_SETTINGS,
BUILTIN_OVERSIGHT_SETTINGS,
DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS,
PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID,
renderTriagePolicyPlaceholders,
} from "./builtin-workflow-settings.js";
export {
@@ -497,6 +501,7 @@ export {
resolveEffectiveSettingsById,
resolveOptionalReviewRevisionBudget,
resolveEffectivePlannerOversightLevel,
resolveEffectivePlannerHeartbeatPatrolEnabled,
PLAN_REVIEW_MAX_REVISIONS_SETTING_ID,
CODE_REVIEW_MAX_REVISIONS_SETTING_ID,
type WorkflowSettingsResolverStore,

View File

@@ -161,6 +161,9 @@ export * from "./shared-mesh-state.js";
export {
BUILTIN_AGENT_PROMPTS,
resolveAgentPrompt,
buildTriageHeartbeatGuidance,
buildConciseTriageHeartbeatGuidance,
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
getAvailableTemplates,
getTemplatesForRole,
FUSION_RUNTIME_SELF_AWARENESS,
@@ -256,6 +259,7 @@ export {
BUILTIN_TRIAGE_POLICY_SETTINGS,
BUILTIN_OVERSIGHT_SETTINGS,
DEFAULT_PLANNER_OVERSEER_EXECUTOR_STUCK_AFTER_MS,
PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID,
renderTriagePolicyPlaceholders,
} from "./builtin-workflow-settings.js";
export {
@@ -486,6 +490,7 @@ export {
resolveEffectiveSettingsById,
resolveOptionalReviewRevisionBudget,
resolveEffectivePlannerOversightLevel,
resolveEffectivePlannerHeartbeatPatrolEnabled,
PLAN_REVIEW_MAX_REVISIONS_SETTING_ID,
CODE_REVIEW_MAX_REVISIONS_SETTING_ID,
type WorkflowSettingsResolverStore,

View File

@@ -33,7 +33,7 @@ import {
type WorkflowIrResolverStore,
} from "./workflow-ir-resolver.js";
import { resolveEffectiveSettingValues, findOrphanedSettingValues } from "./workflow-settings.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
import { BUILTIN_WORKFLOW_SETTINGS, PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID } from "./builtin-workflow-settings.js";
import type { WorkflowSettingDefinition, WorkflowIr, WorkflowOptionalGroupConfig } from "./workflow-ir-types.js";
import { PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, type PlannerOversightLevel } from "./types.js";
@@ -259,3 +259,16 @@ export function resolveEffectivePlannerOversightLevel(
}
return DEFAULT_PLANNER_OVERSIGHT_LEVEL;
}
/*
* FNXC:HeartbeatPatrol 2026-07-14-23:36:
* Boolean workflow values can arrive from built-in declaration defaults or explicit stored overrides. Normalize the idle-heartbeat patrol flag in one place so engine prompt code preserves default-on compatibility while treating only explicit false as no-patrol.
*/
export function resolveEffectivePlannerHeartbeatPatrolEnabled(
workflowEffective: Record<string, unknown> | boolean | null | undefined,
): boolean {
const value = typeof workflowEffective === "object" && workflowEffective !== null
? workflowEffective[PLANNER_HEARTBEAT_PATROL_ENABLED_SETTING_ID]
: workflowEffective;
return value !== false;
}

View File

@@ -13,7 +13,16 @@ import {
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import { expectAppendAgentLog } from "./agent-log-assertions.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message } from "@fusion/core";
import {
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
type AgentStore,
type AgentHeartbeatRun,
type TaskStore,
type TaskDetail,
type Agent,
type MessageStore,
type Message,
} from "@fusion/core";
import { createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js";
vi.mock("../logger.js", async () => {
const { createMockLogger, formatMockError } = await import("./heartbeat-test-helpers.js");
@@ -1592,6 +1601,48 @@ describe("executeHeartbeat", () => {
expect(systemPrompt).toContain("fn_heartbeat_done");
});
it("no-task run gates proactive patrol prompts from workflow setting", async () => {
for (const [storedValue, shouldPatrol] of [[undefined, true], [true, true], [false, false]] as const) {
vi.clearAllMocks();
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
const taskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({ defaultWorkflowId: "builtin:coding" }),
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("test-project"),
getWorkflowSettingValues: vi.fn().mockReturnValue(
storedValue === undefined ? {} : { plannerHeartbeatPatrolEnabled: storedValue },
),
} as Partial<TaskStore>);
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(result.status).toBe("completed");
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
const systemPrompt = callArgs.systemPrompt;
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] ?? "";
const savedRun = await store.getRunDetail("agent-001", result.id);
if (shouldPatrol) {
expect(systemPrompt).toContain("Use fn_task_create to spawn follow-up work");
expect(executionPrompt).toContain("create a focused task instead of attempting unscheduled implementation");
expect(savedRun?.systemPrompt).toContain("Use fn_task_create to spawn follow-up work");
expect(savedRun?.executionPrompt).toContain("create a focused task instead of attempting unscheduled implementation");
expect(systemPrompt).not.toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
} else {
expect(systemPrompt).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(executionPrompt).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(savedRun?.systemPrompt).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(savedRun?.executionPrompt).toContain(TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION);
expect(systemPrompt).not.toContain("Use fn_task_create to spawn follow-up work");
expect(executionPrompt).not.toContain("create a focused task instead of attempting unscheduled implementation");
}
}
});
it("identity agent without task receives no-task execution prompt mentioning 'no assigned task'", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
const mockSession = createMockAgentSession();
@@ -3524,7 +3575,7 @@ describe("executeHeartbeat", () => {
expect(taskLogTool.name).toBe("fn_task_log");
});
it("passes execution settings model ahead of stale runtime model", async () => {
it("uses complete assigned runtime model ahead of shared execution settings", async () => {
const store = createStoreWithAgentForExec({
runtimeConfig: { model: "anthropic/claude-sonnet-4-5" },
});
@@ -3545,8 +3596,8 @@ describe("executeHeartbeat", () => {
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
expect(callArgs.defaultProvider).toBe("openai");
expect(callArgs.defaultModelId).toBe("gpt-4.1");
expect(callArgs.defaultProvider).toBe("anthropic");
expect(callArgs.defaultModelId).toBe("claude-sonnet-4-5");
expect(callArgs.fallbackProvider).toBeUndefined();
expect(callArgs.fallbackModelId).toBeUndefined();
});

View File

@@ -33,6 +33,9 @@ import {
AWAITING_APPROVAL_PAUSE_REASON,
rankAssignedTasksForWakeDelta,
formatAssignedTasksWakeDeltaSection,
resolveEffectiveSettingsById,
resolveEffectivePlannerHeartbeatPatrolEnabled,
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
} from "@fusion/core";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "@earendil-works/pi-ai";
@@ -109,6 +112,27 @@ function adjustHeartbeatMemoryPrimer(basePrompt: string, mode: AgentMemoryInclus
);
}
async function resolveNoTaskHeartbeatPatrolEnabled(
taskStore: TaskStore,
settings: Settings | undefined,
): Promise<boolean> {
try {
const projectId = typeof taskStore.getWorkflowSettingsProjectId === "function"
? taskStore.getWorkflowSettingsProjectId()
: "default";
const workflowId = settings?.defaultWorkflowId || "builtin:coding";
/*
FNXC:HeartbeatPatrol 2026-07-15-00:10:
No-task heartbeats have no task-selected workflow, so idle patrol policy resolves through the project default workflow. If a project has not selected one, built-in coding supplies the compatibility default (`plannerHeartbeatPatrolEnabled: true`). This keeps idle-agent patrol separate from per-task planner oversight recovery.
*/
const effective = await resolveEffectiveSettingsById(taskStore, workflowId, projectId);
return resolveEffectivePlannerHeartbeatPatrolEnabled(effective);
} catch (error: unknown) {
heartbeatLog.warn(`Failed to resolve no-task heartbeat patrol setting: ${error instanceof Error ? error.message : String(error)} — defaulting enabled`);
return true;
}
}
interface SelfImproveServiceLike {
shouldRunSelfImprove(agentId: string): Promise<boolean>;
getSelfImprovePrompt(agentId: string): Promise<string>;
@@ -662,6 +686,45 @@ When sending messages:
- Include relevant context (task IDs, file paths) in metadata when applicable.
- Use agent-to-agent for inter-agent communication.`;
/*
FNXC:HeartbeatPatrol 2026-07-15-00:09:
Operators need to disable idle/no-task proactive task creation without disabling planner oversight for tasks already in flight. Keep the exported legacy constants as the default patrol-on prompt, and render patrol-off variants only when the workflow setting is explicitly false so existing callers remain compatible.
*/
export function renderHeartbeatNoTaskSystemPrompt(options: { plannerHeartbeatPatrolEnabled?: boolean } = {}): string {
if (options.plannerHeartbeatPatrolEnabled !== false) {
return HEARTBEAT_NO_TASK_SYSTEM_PROMPT;
}
return HEARTBEAT_NO_TASK_SYSTEM_PROMPT
.replace(
"2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory.",
"2. Do ONE useful action: analyze, respond to direct messages or explicit operator requests, delegate already-requested work, or update memory.",
)
.replace(
"4. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate.",
`4. ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`,
)
.replace(
"- DO: create a clearly scoped task for a newly discovered reliability issue.\n",
"",
)
.replace(
"- **fn_task_create:** create executable work when ownership is not predetermined.",
`- **Idle patrol disabled:** ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`,
)
.replace(
"If unsure who should do the work, prefer fn_task_create and let scheduler routing happen naturally.",
"If unsure who should do the work, do not create a patrol task; no-op with reason, handle an explicit request, or ask for clarification when available.",
)
.replace(
"- **Unowned risk discovered:** create one focused task with concrete acceptance language.",
"- **Unowned risk discovered:** do not create a patrol task; record durable context only when it is safe and useful, or wait for explicit operator direction.",
)
.replace(
"- **Message requests action:** reply first, then create/delegate follow-up work when execution is required.",
"- **Message requests action:** reply first, then delegate only when ownership is clear or create follow-up work only when the message/operator explicitly requests it.",
);
}
// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_SYSTEM_PROMPT.
export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT;
@@ -881,6 +944,36 @@ export const HEARTBEAT_NO_TASK_PROCEDURE_OFF = `## Heartbeat Procedure (run ever
Critical: a heartbeat without observable progress (or an explicit no-op reason) is a bug.`;
export function renderHeartbeatNoTaskProcedure(
procedure: string,
options: { plannerHeartbeatPatrolEnabled?: boolean } = {},
): string {
if (options.plannerHeartbeatPatrolEnabled !== false) {
return procedure;
}
return procedure
.replace(
" - **Implementation-scope discovery:** code/product work that needs a task;\n create a focused task instead of attempting unscheduled implementation.",
` - **Implementation-scope discovery:** code/product work that needs a task;\n ${TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION}`,
)
.replace(
"6. **Pick the next concrete action** — exactly ONE useful action this heartbeat:\n create a focused task, delegate work, send/reply to a message, or append\n durable memory. Never retry checkout/claim conflicts.",
"6. **Pick the next concrete action** — exactly ONE useful action this heartbeat:\n respond to direct messages, delegate explicitly requested work, append durable\n memory, or no-op with reason. Never retry checkout/claim conflicts.",
)
.replace(
"7. **Persist progress** — use available ambient tools only:\n fn_task_create, fn_delegate_task, fn_send_message, fn_memory_append.",
"7. **Persist progress** — use available ambient tools only for non-patrol work:\n fn_delegate_task, fn_send_message, fn_memory_append, or an explicit no-op reason.\n Do not call fn_task_create for idle patrol task creation.",
)
.replace(
"8. **Final disposition checklist** — acted with evidence / follow-up created or\n delegated / explicit no-op with reason.",
"8. **Final disposition checklist** — acted with evidence / delegated explicit\n requested work / explicit no-op with reason.",
)
.replace(
"Critical: a heartbeat without observable progress (a created task, delegation,\nmessage reply, memory append, or explicit \"no-op with reason\") is a bug.",
"Critical: a heartbeat without observable progress (delegation for explicit work,\nmessage reply, memory append, or explicit \"no-op with reason\") is a bug.",
);
}
// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_PROCEDURE_STRICT.
export const HEARTBEAT_NO_TASK_PROCEDURE = HEARTBEAT_NO_TASK_PROCEDURE_STRICT;
@@ -2851,8 +2944,13 @@ export class HeartbeatMonitor {
globalSettings: memorySettings,
});
const priorMemoryMode = agent.runtimeConfig?.lastAgentMemoryInclusionMode;
const plannerHeartbeatPatrolEnabled = isNoTaskRun
? await resolveNoTaskHeartbeatPatrolEnabled(taskStore, heartbeatModelSettings)
: true;
const baseHeartbeatSystemPrompt = adjustHeartbeatMemoryPrimer(
isNoTaskRun ? HEARTBEAT_NO_TASK_SYSTEM_PROMPT : HEARTBEAT_SYSTEM_PROMPT,
isNoTaskRun
? renderHeartbeatNoTaskSystemPrompt({ plannerHeartbeatPatrolEnabled })
: HEARTBEAT_SYSTEM_PROMPT,
resolvedMemoryMode.mode,
);
let resolvedInstructionsText = "";
@@ -3326,9 +3424,12 @@ export class HeartbeatMonitor {
off: HEARTBEAT_NO_TASK_PROCEDURE_OFF,
},
});
const heartbeatProcedureText = shouldOverrideCustomProcedureForNoTaskRun
const rawHeartbeatProcedureText = shouldOverrideCustomProcedureForNoTaskRun
? resolvedProcedureTemplate
: (customProcedure ?? resolvedProcedureTemplate);
const heartbeatProcedureText = isNoTaskRun
? renderHeartbeatNoTaskProcedure(rawHeartbeatProcedureText, { plannerHeartbeatPatrolEnabled })
: rawHeartbeatProcedureText;
// Precedence: heartbeatProcedurePath (custom file) > resolved heartbeatScopeDiscipline template > strict default.
const heartbeatProcedureSource = shouldOverrideCustomProcedureForNoTaskRun
? "default-no-task-override"
@@ -3390,6 +3491,28 @@ export class HeartbeatMonitor {
),
]
: [];
const noTaskActionGuidanceLines = plannerHeartbeatPatrolEnabled
? [
"2. **Create new tasks** — Use fn_task_create for net-new executable work.",
" Prefer concrete tasks with clear outcomes; avoid vague placeholders.",
"",
]
: [
"2. **Idle patrol disabled** — Do not create new tasks during idle/no-task heartbeats.",
" Only handle assigned work, direct messages, explicit operator requests, and safe read-only/logging coordination.",
"",
];
const noTaskFlowGuidanceLines = plannerHeartbeatPatrolEnabled
? [
"5. **Monitor project flow** — Review board/project signals and surface issues",
" by creating or delegating follow-up work as appropriate.",
"",
]
: [
"5. **Monitor project flow** — Review board/project signals only for safe coordination.",
" Do not spawn patrol tasks from idle observations; no-op with reason when no explicit action is needed.",
"",
];
executionPrompt = [
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
@@ -3424,18 +3547,14 @@ export class HeartbeatMonitor {
"1. **Check your messages** — Use fn_read_messages to review pending messages.",
" If replying, use fn_send_message and include reply_to_message_id so threads stay linked.",
"",
"2. **Create new tasks** — Use fn_task_create for net-new executable work.",
" Prefer concrete tasks with clear outcomes; avoid vague placeholders.",
"",
...noTaskActionGuidanceLines,
"3. **Delegate work** — Use fn_list_agents to find available specialists, then",
" fn_delegate_task when immediate ownership by a specific agent is beneficial.",
"",
"4. **Update memory** — Use fn_memory_append for durable, reusable learnings",
" (conventions, pitfalls, architecture constraints), not transient chatter.",
"",
"5. **Monitor project flow** — Review board/project signals and surface issues",
" by creating or delegating follow-up work as appropriate.",
"",
...noTaskFlowGuidanceLines,
"When auto-claim relevant tasks is enabled, review Open Task Candidates above and",
"prioritize tasks that align with your role and soul before creating net-new tasks.",
...candidateLines,

View File

@@ -20,6 +20,8 @@ import {
resolveAgentPrompt,
builtinSeamPrompt,
renderTriagePolicyPlaceholders,
resolveEffectiveSettings,
resolveEffectivePlannerHeartbeatPatrolEnabled,
resolveTaskPlanningPrompt,
resolveTaskSeamPrompt,
resolvePersistAgentThinkingLog,
@@ -1142,11 +1144,13 @@ export class TriageProcessor {
const workflowFastPlanningPrompt = leanPlanning
? await resolveTaskSeamPrompt(this.store, task.id, "planning-fast").catch(() => undefined)
: undefined;
const effectiveWorkflowSettings = await resolveEffectiveSettings(this.store, task).catch(() => ({}));
const plannerHeartbeatPatrolEnabled = resolveEffectivePlannerHeartbeatPatrolEnabled(effectiveWorkflowSettings);
// FN-6232: standard-mode built-in triage policy is sourced from the workflow IR planning node; the former engine duplicate was removed.
const userTriagePrompt = settings.agentPrompts?.roleAssignments?.triage
? resolveAgentPrompt("triage", settings.agentPrompts)
? resolveAgentPrompt("triage", settings.agentPrompts, { plannerHeartbeatPatrolEnabled })
: "";
const defaultTriagePrompt = resolveAgentPrompt("triage");
const defaultTriagePrompt = resolveAgentPrompt("triage", undefined, { plannerHeartbeatPatrolEnabled });
const resolvedBasePrompt = userTriagePrompt
|| (leanPlanning
? (workflowFastPlanningPrompt || builtinSeamPrompt("planning-fast") || defaultTriagePrompt)