fix(FN-3005): preserve card timer across reruns
This commit is contained in:
@@ -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 }[] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user