feat(FN-1255): add comment-aware heartbeat wakes and blocked-state dedup

- Add BlockedStateSnapshot typing/export and AgentStore persistence APIs for last blocked heartbeat state
- Deduplicate blocked-task heartbeat comments using blockedBy + context hash, and clear blocked snapshots when tasks are no longer blocked
- Thread triggeringCommentIds/triggeringCommentType through heartbeat execution, wake context, scheduler assignment triggers, and runtime wiring
- Trigger immediate heartbeat runs from task/steering comment routes for assigned immediate-response agents, with validation for comment wake fields on /api/agents/:id/runs
- Expand core, engine, and dashboard tests to cover blocked dedup logic, comment-triggered wakes, validation, and skip scenarios
This commit is contained in:
gsxdsm
2026-04-08 18:38:06 -07:00
parent 8b538988eb
commit a05cfd4b55
10 changed files with 985 additions and 9 deletions

View File

@@ -1753,6 +1753,50 @@ describe("AgentStore", () => {
});
});
// ── blocked state persistence ─────────────────────────────────────
describe("blocked state persistence", () => {
it("roundtrips last blocked state via set/get", async () => {
const agent = await store.createAgent({ name: "BlockedState", role: "executor" });
const snapshot = {
taskId: "FN-123",
blockedBy: "FN-122",
recordedAt: new Date().toISOString(),
contextHash: "abc123hash",
};
await store.setLastBlockedState(agent.id, snapshot);
const loaded = await store.getLastBlockedState(agent.id);
expect(loaded).toEqual(snapshot);
});
it("returns null when no blocked-state file exists", async () => {
const agent = await store.createAgent({ name: "NoBlockedState", role: "executor" });
const loaded = await store.getLastBlockedState(agent.id);
expect(loaded).toBeNull();
});
it("clearLastBlockedState removes persisted snapshot", async () => {
const agent = await store.createAgent({ name: "ClearBlockedState", role: "executor" });
await store.setLastBlockedState(agent.id, {
taskId: "FN-999",
blockedBy: "FN-998",
recordedAt: new Date().toISOString(),
contextHash: "will-clear",
});
await store.clearLastBlockedState(agent.id);
const loaded = await store.getLastBlockedState(agent.id);
expect(loaded).toBeNull();
expect(existsSync(join(rootDir, "agents", `${agent.id}-last-blocked.json`))).toBe(false);
});
});
// ── getAgentDetail ────────────────────────────────────────────────
describe("getAgentDetail", () => {

View File

@@ -28,6 +28,7 @@ import type {
AgentApiKeyCreateResult,
AgentHeartbeatEvent,
AgentHeartbeatRun,
BlockedStateSnapshot,
AgentDetail,
AgentBudgetConfig,
AgentBudgetStatus,
@@ -1094,6 +1095,7 @@ export class AgentStore extends EventEmitter {
const agentPath = join(this.agentsDir, `${agentId}.json`);
const heartbeatPath = join(this.agentsDir, `${agentId}-heartbeats.jsonl`);
const revisionsPath = this.getConfigRevisionsPath(agentId);
const blockedStatePath = this.getLastBlockedStatePath(agentId);
// Verify agent exists
const agent = await this.getAgent(agentId);
@@ -1105,6 +1107,7 @@ export class AgentStore extends EventEmitter {
await unlink(agentPath).catch(() => {});
await unlink(heartbeatPath).catch(() => {});
await unlink(revisionsPath).catch(() => {});
await unlink(blockedStatePath).catch(() => {});
// Clean up sessions and runs directories
const { rm } = await import("node:fs/promises");
@@ -1552,6 +1555,41 @@ export class AgentStore extends EventEmitter {
.slice(0, limit);
}
/**
* Get the most recently persisted blocked-task dedup state for an agent.
*/
async getLastBlockedState(agentId: string): Promise<BlockedStateSnapshot | null> {
const blockedStatePath = this.getLastBlockedStatePath(agentId);
try {
const content = await readFile(blockedStatePath, "utf-8");
return JSON.parse(content) as BlockedStateSnapshot;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
}
}
/**
* Persist the latest blocked-task dedup state for an agent.
*/
async setLastBlockedState(agentId: string, state: BlockedStateSnapshot): Promise<void> {
await this.withLock(agentId, async () => {
const blockedStatePath = this.getLastBlockedStatePath(agentId);
await writeFile(blockedStatePath, JSON.stringify(state, null, 2));
});
}
/**
* Clear any persisted blocked-task dedup state for an agent.
*/
async clearLastBlockedState(agentId: string): Promise<void> {
await this.withLock(agentId, async () => {
await unlink(this.getLastBlockedStatePath(agentId)).catch(() => {});
});
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
@@ -1783,6 +1821,10 @@ export class AgentStore extends EventEmitter {
return join(this.agentsDir, `${agentId}-keys.jsonl`);
}
private getLastBlockedStatePath(agentId: string): string {
return join(this.agentsDir, `${agentId}-last-blocked.json`);
}
private async readApiKeys(agentId: string): Promise<AgentApiKey[]> {
const keyPath = this.getApiKeysPath(agentId);
if (!existsSync(keyPath)) {

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -1719,6 +1719,18 @@ export interface AgentHeartbeatEvent {
/** What triggered a heartbeat run */
export type HeartbeatInvocationSource = "on_demand" | "timer" | "assignment" | "automation";
/** Snapshot of the last blocked state for a task, used for dedup comparison. */
export interface BlockedStateSnapshot {
/** The task ID that was blocked */
taskId: string;
/** What the task was blocked by (dependency IDs, overlapping task ID) */
blockedBy: string;
/** ISO-8601 timestamp when this blocked state was recorded */
recordedAt: string;
/** Hash of relevant context at the time (comment count, last comment ID) */
contextHash: string;
}
/** A continuous heartbeat session/run for an agent */
export interface AgentHeartbeatRun {
/** Unique identifier for this run */
@@ -1747,7 +1759,10 @@ export interface AgentHeartbeatRun {
usageJson?: { inputTokens: number; outputTokens: number; cachedTokens: number };
/** Structured result from the run */
resultJson?: Record<string, unknown>;
/** Snapshot of context at run start (taskId, projectId, etc.) */
/** Snapshot of context at run start (taskId, projectId, etc.).
* May include optional comment-wake fields:
* - `triggeringCommentIds?: string[]`
* - `triggeringCommentType?: "steering" | "task" | "pr"` */
contextSnapshot?: Record<string, unknown>;
/** Excerpt of stdout output */
stdoutExcerpt?: string;