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:
@@ -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", () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user