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

@@ -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)) {