fix(FN-096): explain role-filtered auto-claim candidates

This commit is contained in:
Phil Larson
2026-05-29 18:25:19 -07:00
parent 668e3a5099
commit 53d97e2c6f
3 changed files with 66 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Clarify no-task heartbeat prompts when eligible Todo tasks exist but role policy filters them out of auto-claim candidates.

View File

@@ -986,6 +986,42 @@ describe("executeHeartbeat", () => {
expect(store.claimTaskForAgent).not.toHaveBeenCalled(); expect(store.claimTaskForAgent).not.toHaveBeenCalled();
}); });
it("explains empty auto-claim prompt candidates when role policy filters eligible todo tasks", async () => {
const store = createStoreWithAgentForExec({
taskId: undefined,
role: "reviewer",
soul: "review workflows",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
mockTaskStore = createMockTaskStore({
listTasks: vi.fn().mockResolvedValue([
{
id: "FN-CANDIDATE",
description: "executor reliability follow-up",
title: "Executor reliability",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail,
]),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
expect(executionPrompt).toContain("auto-claim relevant tasks: enabled (no role-compatible candidates; executor role required)");
expect(executionPrompt).toContain("Open Task Candidates (auto-claim scan):");
expect(executionPrompt).toContain("Snapshot found 1 eligible Todo task(s), but this agent role cannot auto-claim implementation work.");
});
it("reuses one snapshot rebuild across concurrent no-task heartbeats", async () => { it("reuses one snapshot rebuild across concurrent no-task heartbeats", async () => {
const listTasks = vi.fn().mockResolvedValue([ const listTasks = vi.fn().mockResolvedValue([
{ {

View File

@@ -2057,12 +2057,16 @@ export class HeartbeatMonitor {
} }
let autoClaimCandidates: AutoClaimCandidate[] = []; let autoClaimCandidates: AutoClaimCandidate[] = [];
let autoClaimSnapshotCandidateCount = 0;
let autoClaimRoleFilteredCount = 0;
const autoClaimEnabled = isAutoClaimRelevantTasksEnabled(agent); const autoClaimEnabled = isAutoClaimRelevantTasksEnabled(agent);
if (!taskId && canRunNoTaskHeartbeat && autoClaimEnabled && this.snapshotManager) { if (!taskId && canRunNoTaskHeartbeat && autoClaimEnabled && this.snapshotManager) {
try { try {
const snapshot = await this.snapshotManager.getSnapshot(); const snapshot = await this.snapshotManager.getSnapshot();
autoClaimSnapshotCandidateCount = snapshot.tasks.length;
const roleCompatibleCandidates = snapshot.tasks.filter((candidate) => canAgentTakeImplementationTask(agent, candidate)); const roleCompatibleCandidates = snapshot.tasks.filter((candidate) => canAgentTakeImplementationTask(agent, candidate));
const skippedIncompatibleCount = snapshot.tasks.length - roleCompatibleCandidates.length; const skippedIncompatibleCount = snapshot.tasks.length - roleCompatibleCandidates.length;
autoClaimRoleFilteredCount = skippedIncompatibleCount;
if (skippedIncompatibleCount > 0) { if (skippedIncompatibleCount > 0) {
heartbeatLog.log( heartbeatLog.log(
`Agent ${agentId} (role=${agent.role}) skipped auto-claim of ${skippedIncompatibleCount} implementation task(s) — only executor agents may claim implementation work`, `Agent ${agentId} (role=${agent.role}) skipped auto-claim of ${skippedIncompatibleCount} implementation task(s) — only executor agents may claim implementation work`,
@@ -2717,13 +2721,30 @@ export class HeartbeatMonitor {
} }
const promptCandidateLimit = resolveAutoClaimCandidatesInPromptLimit(agent, heartbeatModelSettings); const promptCandidateLimit = resolveAutoClaimCandidatesInPromptLimit(agent, heartbeatModelSettings);
const autoClaimStatus = autoClaimEnabled
? (promptCandidateLimit === 0
? "disabled (prompt-suppressed)"
: (autoClaimCandidates.length === 0 && autoClaimSnapshotCandidateCount > 0 && autoClaimRoleFilteredCount > 0
? "enabled (no role-compatible candidates; executor role required)"
: "enabled"))
: "disabled";
const noRoleCompatibleCandidateLines = autoClaimCandidates.length === 0 && autoClaimSnapshotCandidateCount > 0 && autoClaimRoleFilteredCount > 0
? [
`- Snapshot found ${autoClaimSnapshotCandidateCount} eligible Todo task(s), but this agent role cannot auto-claim implementation work.`,
"- Backlog auto-claim is restricted to executor-role agents; use delegation or create coordination follow-up instead of assuming the board is empty.",
]
: [];
const candidateLines = promptCandidateLimit > 0 const candidateLines = promptCandidateLimit > 0
? [ ? [
"", "",
"Open Task Candidates (auto-claim scan):", "Open Task Candidates (auto-claim scan):",
...autoClaimCandidates ...(
.slice(0, promptCandidateLimit) autoClaimCandidates.length > 0
.map((candidate) => `- ${candidate.id}: ${candidate.title ?? candidate.descriptionFirstLine}`), ? autoClaimCandidates
.slice(0, promptCandidateLimit)
.map((candidate) => `- ${candidate.id}: ${candidate.title ?? candidate.descriptionFirstLine}`)
: noRoleCompatibleCandidateLines
),
] ]
: []; : [];
@@ -2741,7 +2762,7 @@ export class HeartbeatMonitor {
...(wakeTriggerSourceLine ? [wakeTriggerSourceLine] : []), ...(wakeTriggerSourceLine ? [wakeTriggerSourceLine] : []),
`- pending messages: ${pendingMessages.length}`, `- pending messages: ${pendingMessages.length}`,
`- pending room messages: ${pendingRoomMessages.total}`, `- pending room messages: ${pendingRoomMessages.total}`,
`- auto-claim relevant tasks: ${autoClaimEnabled ? (promptCandidateLimit === 0 ? "disabled (prompt-suppressed)" : "enabled") : "disabled"}`, `- auto-claim relevant tasks: ${autoClaimStatus}`,
"", "",
"Treat this wake delta as the highest-priority change for this heartbeat.", "Treat this wake delta as the highest-priority change for this heartbeat.",
"This is an autonomous heartbeat run (manual or automatic): re-anchor on", "This is an autonomous heartbeat run (manual or automatic): re-anchor on",