fix(FN-3005): preserve card timer across reruns

This commit is contained in:
gsxdsm
2026-04-29 23:13:38 -07:00
parent d81fc9b2c7
commit bb9b0f1d1a
20 changed files with 258 additions and 361 deletions

View File

@@ -6220,6 +6220,30 @@ Task with acceptance criteria
});
});
describe("execution timing timestamps", () => {
it("preserves the original executionStartedAt across an internal rerun bounce", async () => {
const task = await store.createTask({ description: "retry bounce timing" });
await store.moveTask(task.id, "todo");
const started = await store.moveTask(task.id, "in-progress");
const originalExecutionStartedAt = started.executionStartedAt;
expect(originalExecutionStartedAt).toBeDefined();
await new Promise((r) => setTimeout(r, 10));
const bouncedToTodo = await store.moveTask(task.id, "todo");
expect(bouncedToTodo.executionStartedAt).toBeUndefined();
await store.updateTask(task.id, {
worktree: "/tmp/retry-bounce",
executionStartedAt: originalExecutionStartedAt ?? null,
});
const bouncedBack = await store.moveTask(task.id, "in-progress");
expect(bouncedBack.executionStartedAt).toBe(originalExecutionStartedAt);
});
});
describe("settings:updated event", () => {
it("fires on updateSettings with correct old and new values", async () => {
const events: { settings: any; previous: any }[] = [];

View File

@@ -7,9 +7,8 @@
* edited as normal project files.
*/
import { mkdir, readFile, writeFile, readdir, unlink, rename, access } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { mkdir, readFile, writeFile, readdir, unlink, rename } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import { randomUUID, randomBytes, createHash } from "node:crypto";
import { EventEmitter } from "node:events";
import type {
@@ -37,7 +36,7 @@ import type {
AgentRatingInput,
Task,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath } from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH } from "./types.js";
import type { RunMutationContext } from "./types.js";
import type { TaskStore } from "./store.js";
import { computeAccessState } from "./agent-permissions.js";
@@ -212,92 +211,9 @@ export class AgentStore extends EventEmitter {
* Should be called before other operations.
*/
async init(): Promise<void> {
void this.db;
const _ = this.db;
await mkdir(this.agentsDir, { recursive: true });
await this.importLegacyFileDataOnce();
await this.migrateHeartbeatProcedurePathOnce();
}
/**
* One-shot migration that re-points every non-ephemeral agent off the
* legacy shared `.fusion/HEARTBEAT.md` path onto their own per-agent
* `.fusion/agents/<id>/HEARTBEAT.md` file. The legacy file's contents
* are copied to the new location when present so operator edits are
* preserved across the upgrade. The legacy file itself is left in place
* — the migration is non-destructive in case the operator wants a
* reference copy.
*
* Idempotent: tracks completion in the `__meta` table and short-circuits
* on subsequent calls. Failures during file copy are logged via the
* legacy console (no log dependency in core) and do not block startup —
* the agent's `heartbeatProcedurePath` is still flipped, and the engine's
* heartbeat resolver will fall back to the built-in template until the
* file is seeded on next dashboard interaction.
*/
private async migrateHeartbeatProcedurePathOnce(): Promise<void> {
const migrationKey = "heartbeatProcedurePathPerAgent";
const migrationVersion = "1";
const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as
| { value: string }
| undefined;
if (row?.value === migrationVersion) {
return;
}
// The legacy shared file lives at <projectRoot>/.fusion/HEARTBEAT.md.
// `this.rootDir` is already `<projectRoot>/.fusion`, so the file is just
// "HEARTBEAT.md" relative to it.
let legacyContent: string | null = null;
try {
legacyContent = await readFile(join(this.rootDir, "HEARTBEAT.md"), "utf-8");
} catch {
legacyContent = null;
}
const agents = await this.listAgents({ includeEphemeral: false });
let migratedCount = 0;
for (const agent of agents) {
if (agent.heartbeatProcedurePath !== DEFAULT_HEARTBEAT_PROCEDURE_PATH) {
continue;
}
const newRelPath = getDefaultHeartbeatProcedurePath(agent.id);
const newAbsPath = join(this.rootDir, "..", newRelPath);
// Best-effort copy of operator edits to the new per-agent location.
// Skip the write when the per-agent file already exists (someone
// could have set this up manually) so we never clobber it.
if (legacyContent !== null) {
try {
await mkdir(dirname(newAbsPath), { recursive: true });
// Only seed the file if it doesn't already exist.
try {
await access(newAbsPath, fsConstants.F_OK);
} catch {
await writeFile(newAbsPath, legacyContent, "utf-8");
}
} catch {
// Non-fatal — proceed with path flip even if the file copy failed.
}
}
const updated: Agent = {
...agent,
heartbeatProcedurePath: newRelPath,
updatedAt: new Date().toISOString(),
};
await this.writeAgent(updated);
migratedCount += 1;
}
this.db.prepare(`
INSERT INTO __meta (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`).run(migrationKey, migrationVersion);
if (migratedCount > 0) {
this.db.bumpLastModified();
}
}
/**
@@ -460,14 +376,11 @@ export class AgentStore extends EventEmitter {
const runtimeConfig = resolveCreationRuntimeConfig(input.runtimeConfig, metadata);
// Default heartbeatProcedurePath for new non-ephemeral agents so operators
// get an editable HEARTBEAT.md file from day one. Each agent gets its
// own per-agent file (under `.fusion/agents/<id>/HEARTBEAT.md`) so
// tweaks to one agent's procedure do not bleed into the rest of the
// team. Ephemeral task workers skip this — they're short-lived and
// don't need persistent procedure files.
// get an editable HEARTBEAT.md file from day one. Ephemeral task workers
// skip this — they're short-lived and don't need persistent procedure files.
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId));
?? (ephemeral ? undefined : DEFAULT_HEARTBEAT_PROCEDURE_PATH);
const agent: Agent = {
id: agentId,

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, 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, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {

View File

@@ -2781,7 +2781,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -2997,6 +2997,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.sessionFile !== undefined) {
task.sessionFile = updates.sessionFile;
}
if (updates.executionStartedAt === null) {
task.executionStartedAt = undefined;
} else if (updates.executionStartedAt !== undefined) {
task.executionStartedAt = updates.executionStartedAt;
}
if (updates.executionCompletedAt === null) {
task.executionCompletedAt = undefined;
} else if (updates.executionCompletedAt !== undefined) {
task.executionCompletedAt = updates.executionCompletedAt;
}
if (updates.workflowStepResults === null) {
task.workflowStepResults = undefined;
} else if (updates.workflowStepResults !== undefined) {

View File

@@ -3141,34 +3141,14 @@ export interface AgentConfigRevision {
}
/**
* Legacy project-relative shared path for the heartbeat procedure markdown
* file. Older builds defaulted every non-ephemeral agent to this single
* file, which prevented per-agent customization. New code should use
* {@link getDefaultHeartbeatProcedurePath} instead. This constant is kept
* exported only so migrations can detect agents still pointing at the
* shared path and re-route them to their own per-agent file.
*
* @deprecated Use {@link getDefaultHeartbeatProcedurePath} for new agent
* creation and upgrade flows.
* Project-relative default path for the per-tick heartbeat procedure markdown
* file. New non-ephemeral agents get this as their `heartbeatProcedurePath`,
* and the engine seeds the file with the built-in HEARTBEAT_PROCEDURE constant
* on first use so operators can edit it freely. Existing agents can be
* upgraded onto this path via the dashboard's "Upgrade Heartbeat" action.
*/
export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md";
/**
* Compute the project-relative default heartbeat procedure file path for a
* given agent. Each agent gets their own editable HEARTBEAT.md so operators
* can tune the per-tick procedure without changes leaking across the team.
*
* The path is laid out under `.fusion/agents/<agentId>/HEARTBEAT.md` so it
* lives alongside any other future per-agent assets and survives agent
* renames (which do not change the immutable agent id).
*/
export function getDefaultHeartbeatProcedurePath(agentId: string): string {
if (!agentId || typeof agentId !== "string") {
throw new Error("getDefaultHeartbeatProcedurePath requires a non-empty agentId");
}
return `.fusion/agents/${agentId}/HEARTBEAT.md`;
}
/** Extract trackable config fields from an Agent into a snapshot */
export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
return {