feat(FN-2943): merge fusion/fn-2943
- **Active Agents panel overhaul**: Hoisted above the main agent list with a new `ActiveAgentsPanel.css` stylesheet; shows next heartbeat ETA and prevents cards from getting stuck in "Connecting..." state - **Node routing policy**: Scheduler now enforces unavailable-node scheduling rules and routes tasks to eligible nodes more reliably (`packages/engine/src/scheduler.ts`, `node-routing-policy.ts`) - **Task card merge timer**: Keeps the card timer live while a task is actively merging rather than resetting on merge start - **Settings modal scroll**: Scrolls to top when auth actions succeed, with regression tests added - **Task steps reset**: Resets steps when reopening a task that returns to todo or triage (FN-2966) - **Core store additions**: New `packages/core/src/store.ts` test coverage for task state mutations - **Dashboard SSE bus**: New `sse-bus.ts` for event bus communication in the dashboard - **Version bumps**: `v0.9.0` release across all packages; `cli-alias`, `dashboard`, `engine`, `plugin-sdk`, and plugin changelogs updated - **Changeset cleanup**: Removed 20 stale changeset files consumed by the v0.9.0 release; added two new ones for Active Agents panel fixes Commits merged: - test(FN-2943): complete Step 3 — add auth scroll-to-top regressions - feat(FN-2943): complete Step 2 — scroll on successful auth actions - feat(FN-2943): complete Step 1 — add settings content scroll ref - feat(FN-2951): enforce unavailable-node scheduling and improve active agents panel - chore: add changeset for Active Agents panel hoist + heartbeat ETA - feat(dashboard): hoist Active Agents panel above main list, show next heartbeat - fix(dashboard): add top margin between agent list and Active Agents panel - fix(dashboard): unstick Active Agents panel cards from "Connecting..." - chore(release): v0.9.0 - chore: add changeset for live merge timer on task card - fix(dashboard): keep card timer live while task is actively merging - feat(FN-2966): reset task steps when reopening to todo or triage Files changed: .changeset/active-agents-live-status-and-logs.md | 13 - .changeset/active-agents-no-stuck-connecting.md | 13 + .changeset/active-agents-panel-hoist-heartbeat.md | 13 + .changeset/dashboard-merge-base-diff.md | 13 - .changeset/dashboard-task-changed-files-only.md | 13 - .changeset/engine-concurrency-races.md | 13 - .changeset/fix-agent-heartbeat-scheduling.md | 5 - .changeset/memory-dream-session-state.md | 13 - .changeset/merger-ai-summarized-commits.md | 13 - .changeset/multi-node-routing.md | 21 - .changeset/pluggable-notification-providers.md | 4 - .changeset/pr-mode-push-branch-and-cleanup.md | 16 - .../recover-mergeable-respects-merge-settings.md | 13 - .changeset/remote-tailscale-qr-tui.md | 7 - .changeset/require-pr-approval-setting.md | 17 - .changeset/respec-from-in-review.md | 13 - .changeset/restore-task-card-fallbacks.md | 13 - .changeset/tui-log-copy-feedback.md | 13 - .changeset/tui-wide-stats-stack.md | 13 - CHANGELOG.md | 449 +++++++++++++++++++++ package.json | 2 +- packages/cli-alias/CHANGELOG.md | 60 +++ packages/cli-alias/package.json | 2 +- packages/cli/CHANGELOG.md | 44 ++ packages/cli/package.json | 2 +- packages/core/CHANGELOG.md | 41 ++ packages/core/package.json | 2 +- packages/core/src/__tests__/store.test.ts | 127 ++++++ packages/core/src/store.ts | 35 +- packages/dashboard/CHANGELOG.md | 63 +++ .../dashboard/app/components/ActiveAgentsPanel.css | 29 ++ .../dashboard/app/components/ActiveAgentsPanel.tsx | 35 +- packages/dashboard/app/components/AgentsView.tsx | 5 +- .../dashboard/app/components/SettingsModal.tsx | 13 +- packages/dashboard/app/components/TaskCard.tsx | 30 +- .../__tests__/ActiveAgentsPanel.test.tsx | 42 ++ .../app/components/__tests__/AgentsView.test.tsx | 11 +- .../components/__tests__/SettingsModal.test.tsx | 68 ++++ .../app/components/__tests__/TaskCard.test.tsx | 36 ++ packages/dashboard/app/sse-bus.ts | 13 + packages/dashboard/package.json | 2 +- packages/desktop/CHANGELOG.md | 41 ++ packages/desktop/package.json | 2 +- packages/engine/CHANGELOG.md | 59 +++ packages/engine/package.json | 2 +- .../src/__tests__/node-routing-policy.test.ts | 102 +++-- .../src/__tests__/scheduler-node-routing.test.ts | 43 +- packages/engine/src/index.ts | 1 + packages/engine/src/node-routing-policy.ts | 41 +- packages/engine/src/scheduler.ts | 48 +-- packages/mobile/CHANGELOG.md | 41 ++ packages/mobile/package.json | 2 +- packages/pi-claude-cli/CHANGELOG.md | 41 ++ packages/pi-claude-cli/package.json | 2 +- packages/plugin-sdk/CHANGELOG.md | 57 +++ packages/plugin-sdk/package.json | 2 +- .../examples/fusion-plugin-auto-label/CHANGELOG.md | 21 + .../examples/fusion-plugin-auto-label/package.json | 2 +- .../examples/fusion-plugin-ci-status/CHANGELOG.md | 21 + .../examples/fusion-plugin-ci-status/package.json | 2 +- .../fusion-plugin-notification/CHANGELOG.md | 21 + .../fusion-plugin-notification/package.json | 2 +- .../fusion-plugin-settings-demo/CHANGELOG.md | 21 + .../fusion-plugin-settings-demo/package.json | 2 +- plugins/fusion-plugin-hermes-runtime/CHANGELOG.md | 21 + plugins/fusion-plugin-hermes-runtime/package.json | 2 +- .../fusion-plugin-openclaw-runtime/CHANGELOG.md | 21 + .../fusion-plugin-openclaw-runtime/package.json | 2 +- .../fusion-plugin-paperclip-runtime/CHANGELOG.md | 21 + .../fusion-plugin-paperclip-runtime/package.json | 2 +- 70 files changed, 1617 insertions(+), 378 deletions(-) Fusion-Task-Id: FN-2943
This commit is contained in:
5
.changeset/fix-agent-heartbeat-terminal-links.md
Normal file
5
.changeset/fix-agent-heartbeat-terminal-links.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Clear stale agent task links when tasks become terminal or are deleted, fall back to no-task heartbeat instruction runs for archived assignments, and expand built-in agent prompts with explicit heartbeat guidance.
|
||||
@@ -251,6 +251,62 @@ describe("resolveAgentPrompt", () => {
|
||||
const result = resolveAgentPrompt("triage", config);
|
||||
expect(result).toContain("task_document_write");
|
||||
});
|
||||
|
||||
it("default role prompts include explicit heartbeat run guidance", () => {
|
||||
expect(resolveAgentPrompt("executor")).toContain("## Heartbeat Run Behavior");
|
||||
expect(resolveAgentPrompt("triage")).toContain("## Heartbeat Run Behavior");
|
||||
expect(resolveAgentPrompt("reviewer")).toContain("## Heartbeat Run Behavior");
|
||||
expect(resolveAgentPrompt("merger")).toContain("## Heartbeat Run Behavior");
|
||||
});
|
||||
|
||||
it("executor heartbeat guidance covers no-task engineering work", () => {
|
||||
const result = resolveAgentPrompt("executor");
|
||||
expect(result).toContain("execute your standing instructions");
|
||||
expect(result).toContain("blocked or failing engineering work");
|
||||
});
|
||||
|
||||
it("reviewer heartbeat guidance is findings-focused and customized per variant", () => {
|
||||
const defaultReviewer = resolveAgentPrompt("reviewer");
|
||||
expect(defaultReviewer).toContain("Look for work waiting on review");
|
||||
|
||||
const strictConfig: AgentPromptsConfig = {
|
||||
roleAssignments: {
|
||||
reviewer: "strict-reviewer",
|
||||
},
|
||||
};
|
||||
const strictReviewer = resolveAgentPrompt("reviewer", strictConfig);
|
||||
expect(strictReviewer).toContain("worst-case failure modes first");
|
||||
expect(strictReviewer).toContain("under-reviewed");
|
||||
});
|
||||
|
||||
it("triage heartbeat guidance is customized for standard and concise templates", () => {
|
||||
const defaultTriage = resolveAgentPrompt("triage");
|
||||
expect(defaultTriage).toContain("Patrol for vague requests");
|
||||
|
||||
const conciseConfig: AgentPromptsConfig = {
|
||||
roleAssignments: {
|
||||
triage: "concise-triage",
|
||||
},
|
||||
};
|
||||
const conciseTriage = resolveAgentPrompt("triage", conciseConfig);
|
||||
expect(conciseTriage).toContain("Keep heartbeat output lean and useful");
|
||||
expect(conciseTriage).toContain("minimum complete PROMPT.md");
|
||||
});
|
||||
|
||||
it("merger and senior-engineer heartbeat guidance is role-specific", () => {
|
||||
const merger = resolveAgentPrompt("merger");
|
||||
expect(merger).toContain("keep merge-ready work from stalling");
|
||||
expect(merger).toContain("in-review and merge-ready queue");
|
||||
|
||||
const seniorConfig: AgentPromptsConfig = {
|
||||
roleAssignments: {
|
||||
executor: "senior-engineer",
|
||||
},
|
||||
};
|
||||
const senior = resolveAgentPrompt("executor", seniorConfig);
|
||||
expect(senior).toContain("autonomous senior-engineering pass");
|
||||
expect(senior).toContain("architectural drift");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,6 +24,7 @@ import { runCommandAsync } from "../run-command.js";
|
||||
const mockedRunCommandAsync = vi.mocked(runCommandAsync);
|
||||
|
||||
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||
import { AgentStore } from "../agent-store.js";
|
||||
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync } from "node:fs";
|
||||
@@ -4626,6 +4627,28 @@ Task with acceptance criteria
|
||||
expect(after.count).toBe(0);
|
||||
});
|
||||
|
||||
it("deleteTask clears linked agent task assignments", async () => {
|
||||
store.close();
|
||||
store = new TaskStore(rootDir, globalDir);
|
||||
await store.init();
|
||||
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
try {
|
||||
const task = await store.createTask({ description: "Delete me" });
|
||||
const agent = await agentStore.createAgent({ name: "Delete watcher", role: "executor" });
|
||||
await agentStore.assignTask(agent.id, task.id);
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
|
||||
const updatedAgent = await agentStore.getAgent(agent.id);
|
||||
expect(updatedAgent?.taskId).toBeUndefined();
|
||||
} finally {
|
||||
agentStore.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("importLegacyAgentLogs imports JSONL entries from existing agent.log files", async () => {
|
||||
const task = await createTestTask();
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
@@ -7727,6 +7750,33 @@ Task with acceptance criteria
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("archiveTask clears stale linked agent assignments", async () => {
|
||||
store.close();
|
||||
store = new TaskStore(rootDir, globalDir);
|
||||
await store.init();
|
||||
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
try {
|
||||
const task = await store.createTask({ description: "Archive clears links" });
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.moveTask(task.id, "done");
|
||||
|
||||
const agent = await agentStore.createAgent({ name: "Archive watcher", role: "executor" });
|
||||
await agentStore.assignTask(agent.id, task.id);
|
||||
|
||||
await store.archiveTask(task.id, false);
|
||||
|
||||
const updatedAgent = await agentStore.getAgent(agent.id);
|
||||
expect(updatedAgent?.taskId).toBeUndefined();
|
||||
} finally {
|
||||
agentStore.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive log persistence", () => {
|
||||
|
||||
@@ -855,6 +855,62 @@ Write a PROMPT.md specification to the given path. Be brief and precise — avoi
|
||||
6. **Read first:** Examine codebase before writing spec.
|
||||
7. **Be concise:** Short descriptions, minimal prose. Focus on what matters.`;
|
||||
|
||||
const EXECUTOR_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior
|
||||
|
||||
Treat each heartbeat as a short autonomous execution cycle.
|
||||
|
||||
- If a task is assigned: inspect the latest task state, continue the next concrete implementation step, run the smallest useful verification, and either advance the task or log the blocker precisely.
|
||||
- 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.`;
|
||||
|
||||
const TRIAGE_HEARTBEAT_GUIDANCE = `## 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.
|
||||
- Favor ambiguity reduction over busywork. Every heartbeat should leave the queue more actionable than you found it.`;
|
||||
|
||||
const REVIEWER_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior
|
||||
|
||||
Use heartbeat runs to keep review quality high and queues moving.
|
||||
|
||||
- If a task is assigned: perform the review with findings first, focusing on correctness, regressions, missing tests, and operational risk.
|
||||
- If no task is assigned: execute your review instructions. Look for work waiting on review, failed validations, suspicious recent changes, and places where a second pass would prevent a bad merge.
|
||||
- Prefer surfacing concrete findings, follow-up tasks, or merge blockers over rewriting implementation yourself.`;
|
||||
|
||||
const MERGER_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior
|
||||
|
||||
Use heartbeat runs to keep merge-ready work from stalling.
|
||||
|
||||
- If a task is assigned: verify merge preconditions, resolve the next safe merge step, and surface conflicts or missing gates immediately.
|
||||
- If no task is assigned: execute your merge instructions. Inspect the in-review and merge-ready queue, look for unresolved conflicts, missing approvals, broken post-review state, and tasks that are ready for the final merge push.
|
||||
- Optimize for safe flow, not raw throughput. Clear blockers, communicate risks, and only move merge work forward when the repository stays trustworthy.`;
|
||||
|
||||
const SENIOR_ENGINEER_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior
|
||||
|
||||
Treat each heartbeat as an autonomous senior-engineering pass.
|
||||
|
||||
- If a task is assigned: push the implementation forward decisively, making sound architectural choices, validating risky changes early, and documenting trade-offs that downstream agents should inherit.
|
||||
- If no task is assigned: execute your standing instructions. Hunt for architectural drift, flaky quality gates, latent integration risk, and follow-up work that needs a strong technical owner.
|
||||
- Spend heartbeat time where leverage is highest: unblock teams, reduce complexity, and turn vague engineering risk into concrete next actions.`;
|
||||
|
||||
const STRICT_REVIEWER_HEARTBEAT_GUIDANCE = `## Heartbeat Run Behavior
|
||||
|
||||
Use heartbeat runs to enforce a high review bar.
|
||||
|
||||
- If a task is assigned: review for worst-case failure modes first, especially security, backward compatibility, edge cases, and missing regression coverage.
|
||||
- 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
|
||||
|
||||
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.
|
||||
- Prefer crisp decisions, clear file scope, and concrete verification steps over narrative detail.`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in templates array
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -866,7 +922,7 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [
|
||||
name: "Default Executor",
|
||||
description: "Standard task execution agent with full tooling and review support.",
|
||||
role: "executor",
|
||||
prompt: EXECUTOR_PROMPT_TEXT,
|
||||
prompt: `${EXECUTOR_PROMPT_TEXT}\n\n${EXECUTOR_HEARTBEAT_GUIDANCE}`,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
@@ -874,7 +930,7 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [
|
||||
name: "Default Triage",
|
||||
description: "Standard task specification agent producing detailed PROMPT.md files.",
|
||||
role: "triage",
|
||||
prompt: TRIAGE_PROMPT_TEXT,
|
||||
prompt: `${TRIAGE_PROMPT_TEXT}\n\n${TRIAGE_HEARTBEAT_GUIDANCE}`,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
@@ -882,7 +938,7 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [
|
||||
name: "Default Reviewer",
|
||||
description: "Standard independent code and plan reviewer with balanced criteria.",
|
||||
role: "reviewer",
|
||||
prompt: REVIEWER_PROMPT_TEXT,
|
||||
prompt: `${REVIEWER_PROMPT_TEXT}\n\n${REVIEWER_HEARTBEAT_GUIDANCE}`,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
@@ -890,7 +946,7 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [
|
||||
name: "Default Merger",
|
||||
description: "Standard merge agent for squash merges with conflict resolution.",
|
||||
role: "merger",
|
||||
prompt: MERGER_BASE_PROMPT_TEXT,
|
||||
prompt: `${MERGER_BASE_PROMPT_TEXT}\n\n${MERGER_HEARTBEAT_GUIDANCE}`,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
@@ -898,7 +954,7 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [
|
||||
name: "Senior Engineer",
|
||||
description: "Autonomous executor with architectural awareness, performance focus, and minimal hand-holding. Makes independent decisions on routine matters.",
|
||||
role: "executor",
|
||||
prompt: SENIOR_ENGINEER_PROMPT_TEXT,
|
||||
prompt: `${SENIOR_ENGINEER_PROMPT_TEXT}\n\n${SENIOR_ENGINEER_HEARTBEAT_GUIDANCE}`,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
@@ -906,7 +962,7 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [
|
||||
name: "Strict Reviewer",
|
||||
description: "Rigorous reviewer with stricter criteria for security, edge cases, backward compatibility, and type safety. Issues REVISE more readily.",
|
||||
role: "reviewer",
|
||||
prompt: STRICT_REVIEWER_PROMPT_TEXT,
|
||||
prompt: `${STRICT_REVIEWER_PROMPT_TEXT}\n\n${STRICT_REVIEWER_HEARTBEAT_GUIDANCE}`,
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
@@ -914,7 +970,7 @@ export const BUILTIN_AGENT_PROMPTS: readonly AgentPromptTemplate[] = [
|
||||
name: "Concise Triage",
|
||||
description: "Shorter, more focused specification format with minimal prose. Produces compact PROMPT.md files with essential information only.",
|
||||
role: "triage",
|
||||
prompt: CONCISE_TRIAGE_PROMPT_TEXT,
|
||||
prompt: `${CONCISE_TRIAGE_PROMPT_TEXT}\n\n${CONCISE_TRIAGE_HEARTBEAT_GUIDANCE}`,
|
||||
builtIn: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2703,6 +2703,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
}
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (toColumn === "done") {
|
||||
this.clearLinkedAgentTaskIds(id, task.updatedAt);
|
||||
}
|
||||
|
||||
// Update cache if watcher is active
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
@@ -3547,6 +3550,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
rewrittenDependents.push(updatedDependent);
|
||||
}
|
||||
|
||||
this.clearLinkedAgentTaskIds(taskId);
|
||||
this.db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
|
||||
this.db.bumpLastModified();
|
||||
});
|
||||
@@ -3554,6 +3558,33 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return rewrittenDependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear `agent.taskId` links that point at a task which has transitioned out
|
||||
* of active work. This keeps heartbeat scheduling aligned with live task
|
||||
* storage and prevents stale task-scoped heartbeat runs.
|
||||
*/
|
||||
private clearLinkedAgentTaskIds(taskId: string, updatedAt: string = new Date().toISOString()): void {
|
||||
const linkedAgents = this.db
|
||||
.prepare("SELECT id FROM agents WHERE taskId = ?")
|
||||
.all(taskId) as Array<{ id: string }>;
|
||||
|
||||
if (linkedAgents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.db.prepare(`
|
||||
UPDATE agents
|
||||
SET
|
||||
taskId = NULL,
|
||||
updatedAt = ?,
|
||||
data = CASE
|
||||
WHEN json_valid(data) THEN json_set(json_remove(data, '$.taskId'), '$.updatedAt', ?)
|
||||
ELSE data
|
||||
END
|
||||
WHERE taskId = ?
|
||||
`).run(updatedAt, updatedAt, taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the git branch associated with a task.
|
||||
*
|
||||
@@ -3887,6 +3918,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
if (!cleanup) {
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
this.clearLinkedAgentTaskIds(id, task.updatedAt);
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
this.emit("task:moved", { task, from: "done" as Column, to: "archived" as Column });
|
||||
return task;
|
||||
@@ -3903,6 +3935,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const entry = await this.taskToArchiveEntry(task, task.columnMovedAt);
|
||||
this.archiveDb.upsert(entry);
|
||||
|
||||
this.clearLinkedAgentTaskIds(id, task.updatedAt);
|
||||
this.db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// See FN-2142 for the rationale.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
HeartbeatMonitor,
|
||||
HeartbeatTriggerScheduler,
|
||||
@@ -1407,6 +1410,83 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.resultJson).toEqual({ reason: "task_not_found", taskId: "FN-MISSING" });
|
||||
});
|
||||
|
||||
it("clears archived task assignments and falls back to a no-task heartbeat for identity agents", async () => {
|
||||
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||
mockTaskStore = createMockTaskStore({
|
||||
appendAgentLog,
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-ARCHIVED",
|
||||
title: "Archived Task",
|
||||
description: "Archived task description",
|
||||
prompt: "# Archived\n\nTask is archived",
|
||||
steps: [],
|
||||
column: "archived",
|
||||
dependencies: [],
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as TaskDetail),
|
||||
});
|
||||
const store = createStoreWithAgentForExec({
|
||||
taskId: "FN-ARCHIVED",
|
||||
soul: "Monitor the project and handle ambient work.",
|
||||
});
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
|
||||
|
||||
expect((store.assignTask as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
undefined,
|
||||
expect.objectContaining({ agentId: "agent-001", source: "on_demand" }),
|
||||
);
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
|
||||
const toolNames = mockedCreateFnAgent.mock.calls[0]![0]!.customTools!.map((tool: any) => tool.name);
|
||||
expect(toolNames).not.toContain("fn_task_log");
|
||||
expect(toolNames).not.toContain("fn_task_document_write");
|
||||
expect(toolNames).not.toContain("fn_task_document_read");
|
||||
expect(appendAgentLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits gracefully for explicit terminal task overrides that are not the agent's current assignment", async () => {
|
||||
mockTaskStore = createMockTaskStore({
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-DONE",
|
||||
title: "Done Task",
|
||||
description: "Done task description",
|
||||
prompt: "# Done\n\nTask is done",
|
||||
steps: [],
|
||||
column: "done",
|
||||
dependencies: [],
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as TaskDetail),
|
||||
});
|
||||
const store = createStoreWithAgentForExec({
|
||||
taskId: "FN-LIVE",
|
||||
soul: "Stay helpful.",
|
||||
});
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const result = await monitor.executeHeartbeat({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
taskId: "FN-DONE",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.resultJson).toEqual({ reason: "terminal_task", taskId: "FN-DONE", column: "done" });
|
||||
expect(store.assignTask).not.toHaveBeenCalledWith("agent-001", undefined, expect.anything());
|
||||
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Identity Agents Without Tasks ─────────────────────────────────────────────
|
||||
@@ -2728,6 +2808,40 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(callArgs.systemPrompt).toContain("## Project Memory");
|
||||
});
|
||||
|
||||
it("includes markdown instructions files plus soul in heartbeat system prompts", async () => {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "heartbeat-agent-instructions-"));
|
||||
writeFileSync(
|
||||
join(rootDir, "heartbeat-agent.md"),
|
||||
"# Heartbeat Playbook\n\nCheck messages first, then create focused follow-up tasks.",
|
||||
);
|
||||
|
||||
try {
|
||||
const store = createStoreWithAgentForExec({
|
||||
instructionsPath: "heartbeat-agent.md",
|
||||
soul: "Operate like a calm, systems-minded operator.",
|
||||
});
|
||||
const taskStore = createMockTaskStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ memoryEnabled: false }),
|
||||
} as Partial<TaskStore>);
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({
|
||||
session: mockSession as any,
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir });
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
||||
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
|
||||
expect(callArgs.systemPrompt).toContain("## Soul");
|
||||
expect(callArgs.systemPrompt).toContain("Operate like a calm, systems-minded operator.");
|
||||
expect(callArgs.systemPrompt).toContain("# Heartbeat Playbook");
|
||||
expect(callArgs.systemPrompt).toContain("create focused follow-up tasks");
|
||||
} finally {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("omits memory tools and instructions when project memory is disabled", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
const taskStore = createMockTaskStore({
|
||||
|
||||
@@ -895,6 +895,7 @@ export class HeartbeatMonitor {
|
||||
// Check if agent has identity (used later for no-task run decisions)
|
||||
const agentHasIdentity = hasAgentIdentity(agent);
|
||||
const isAgentEphemeral = isEphemeralAgent(agent);
|
||||
const canRunNoTaskHeartbeat = agentHasIdentity && !isAgentEphemeral;
|
||||
|
||||
// Resolve task assignment (explicit override → existing assignment → inbox-lite selection)
|
||||
let taskId = explicitTaskId ?? agent.taskId;
|
||||
@@ -951,7 +952,7 @@ export class HeartbeatMonitor {
|
||||
// session even without a task, so they can do ambient work like messaging,
|
||||
// memory management, task creation, and delegation.
|
||||
// Ephemeral agents and agents without identity still exit gracefully.
|
||||
if (!agentHasIdentity || isAgentEphemeral) {
|
||||
if (!canRunNoTaskHeartbeat) {
|
||||
heartbeatLog.log(`Agent ${agentId} has no task assignment — graceful exit`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
@@ -961,7 +962,7 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
heartbeatLog.log(`Agent ${agentId} has no task but has identity — running no-task heartbeat`);
|
||||
}
|
||||
const isNoTaskRun = !taskId;
|
||||
let isNoTaskRun = !taskId;
|
||||
|
||||
// Validate agent state (only for task-scoped runs)
|
||||
if (!isNoTaskRun) {
|
||||
@@ -993,66 +994,116 @@ export class HeartbeatMonitor {
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
// Checkout enforcement: agent must hold the lease to work on this task.
|
||||
// The heartbeat only validates existing checkout state — it does NOT attempt
|
||||
// to acquire a checkout itself. The calling system (scheduler, API trigger)
|
||||
// is responsible for checking out the task before the heartbeat starts.
|
||||
if (taskDetail.checkedOutBy && taskDetail.checkedOutBy !== agentId) {
|
||||
heartbeatLog.warn(
|
||||
`Agent ${agentId} does not hold checkout for ${resolvedTaskId} (held by ${taskDetail.checkedOutBy}) — graceful exit`
|
||||
);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: {
|
||||
reason: "checkout_conflict",
|
||||
taskId: resolvedTaskId,
|
||||
checkedOutBy: taskDetail.checkedOutBy,
|
||||
},
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
if (taskDetail.column === "done" || taskDetail.column === "archived") {
|
||||
if (agent.taskId === resolvedTaskId) {
|
||||
heartbeatLog.log(
|
||||
`Agent ${agentId} linked task ${resolvedTaskId} is ${taskDetail.column} — clearing assignment and running heartbeat without task context`,
|
||||
);
|
||||
try {
|
||||
await this.store.assignTask(agentId, undefined, runContext);
|
||||
} catch (clearErr) {
|
||||
heartbeatLog.warn(
|
||||
`Failed to clear terminal task assignment ${resolvedTaskId} for ${agentId}: ${clearErr instanceof Error ? clearErr.message : String(clearErr)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blockedBy = typeof taskDetail.blockedBy === "string" ? taskDetail.blockedBy.trim() : "";
|
||||
const isBlockedTask = taskDetail.status === "queued" && blockedBy.length > 0;
|
||||
taskId = undefined;
|
||||
taskDetail = undefined;
|
||||
isNoTaskRun = true;
|
||||
|
||||
if (isBlockedTask) {
|
||||
const commentCount = (taskDetail.comments?.length ?? 0) + (taskDetail.steeringComments?.length ?? 0);
|
||||
const lastCommentId = taskDetail.comments?.at(-1)?.id;
|
||||
const lastSteeringCommentId = taskDetail.steeringComments?.at(-1)?.id;
|
||||
const contextHash = Buffer.from(
|
||||
JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy }),
|
||||
)
|
||||
.toString("base64")
|
||||
.slice(0, 16);
|
||||
|
||||
const currentBlockedState: BlockedStateSnapshot = {
|
||||
taskId: resolvedTaskId,
|
||||
blockedBy,
|
||||
recordedAt: new Date().toISOString(),
|
||||
contextHash,
|
||||
};
|
||||
|
||||
const previousBlockedState = await this.store.getLastBlockedState(agentId);
|
||||
if (previousBlockedState && isBlockedStateDuplicate(currentBlockedState, previousBlockedState)) {
|
||||
if (!canRunNoTaskHeartbeat) {
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "no_assignment" },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
} else {
|
||||
heartbeatLog.log(
|
||||
`Heartbeat for ${agentId} targeted terminal task ${resolvedTaskId} (${taskDetail.column}) — graceful exit`,
|
||||
);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked_duplicate", taskId: resolvedTaskId, blockedBy },
|
||||
resultJson: { reason: "terminal_task", taskId: resolvedTaskId, column: taskDetail.column },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNoTaskRun) {
|
||||
heartbeatLog.log(`Agent ${agentId} terminal task assignment resolved into no-task heartbeat`);
|
||||
} else {
|
||||
const liveTaskDetail = taskDetail;
|
||||
if (!liveTaskDetail) {
|
||||
heartbeatLog.warn(`Task ${resolvedTaskId} lost detail after terminal-assignment handling — graceful exit`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "task_not_found", taskId: resolvedTaskId },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
const blockedMessage = `Task is blocked by ${blockedBy}; waiting for dependency/context changes before retrying.`;
|
||||
await taskStore.addComment(resolvedTaskId, blockedMessage, "agent", undefined, runContext);
|
||||
// Audit trail: record comment mutation (FN-1404)
|
||||
await audit.database({ type: "task:comment:add", target: resolvedTaskId, metadata: { blockedBy } });
|
||||
await this.store.setLastBlockedState(agentId, currentBlockedState);
|
||||
// Checkout enforcement: agent must hold the lease to work on this task.
|
||||
// The heartbeat only validates existing checkout state — it does NOT attempt
|
||||
// to acquire a checkout itself. The calling system (scheduler, API trigger)
|
||||
// is responsible for checking out the task before the heartbeat starts.
|
||||
if (liveTaskDetail.checkedOutBy && liveTaskDetail.checkedOutBy !== agentId) {
|
||||
heartbeatLog.warn(
|
||||
`Agent ${agentId} does not hold checkout for ${resolvedTaskId} (held by ${liveTaskDetail.checkedOutBy}) — graceful exit`
|
||||
);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: {
|
||||
reason: "checkout_conflict",
|
||||
taskId: resolvedTaskId,
|
||||
checkedOutBy: liveTaskDetail.checkedOutBy,
|
||||
},
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
heartbeatLog.log(`Task ${resolvedTaskId} is blocked by ${blockedBy} — recorded blocked state`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked", taskId: resolvedTaskId, blockedBy },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
const blockedBy = typeof liveTaskDetail.blockedBy === "string" ? liveTaskDetail.blockedBy.trim() : "";
|
||||
const isBlockedTask = liveTaskDetail.status === "queued" && blockedBy.length > 0;
|
||||
|
||||
if (isBlockedTask) {
|
||||
const commentCount = (liveTaskDetail.comments?.length ?? 0) + (liveTaskDetail.steeringComments?.length ?? 0);
|
||||
const lastCommentId = liveTaskDetail.comments?.at(-1)?.id;
|
||||
const lastSteeringCommentId = liveTaskDetail.steeringComments?.at(-1)?.id;
|
||||
const contextHash = Buffer.from(
|
||||
JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy }),
|
||||
)
|
||||
.toString("base64")
|
||||
.slice(0, 16);
|
||||
|
||||
const currentBlockedState: BlockedStateSnapshot = {
|
||||
taskId: resolvedTaskId,
|
||||
blockedBy,
|
||||
recordedAt: new Date().toISOString(),
|
||||
contextHash,
|
||||
};
|
||||
|
||||
const previousBlockedState = await this.store.getLastBlockedState(agentId);
|
||||
if (previousBlockedState && isBlockedStateDuplicate(currentBlockedState, previousBlockedState)) {
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked_duplicate", taskId: resolvedTaskId, blockedBy },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
const blockedMessage = `Task is blocked by ${blockedBy}; waiting for dependency/context changes before retrying.`;
|
||||
await taskStore.addComment(resolvedTaskId, blockedMessage, "agent", undefined, runContext);
|
||||
// Audit trail: record comment mutation (FN-1404)
|
||||
await audit.database({ type: "task:comment:add", target: resolvedTaskId, metadata: { blockedBy } });
|
||||
await this.store.setLastBlockedState(agentId, currentBlockedState);
|
||||
|
||||
heartbeatLog.log(`Task ${resolvedTaskId} is blocked by ${blockedBy} — recorded blocked state`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked", taskId: resolvedTaskId, blockedBy },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user