fix(FN-3005): preserve card timer across reruns
This commit is contained in:
@@ -2522,8 +2522,7 @@ function HeartbeatProcedureSection({
|
||||
}) {
|
||||
const [isUpgrading, setIsUpgrading] = useState(false);
|
||||
const currentPath = agent.heartbeatProcedurePath?.trim();
|
||||
const expectedDefaultPath = `.fusion/agents/${agent.id}/HEARTBEAT.md`;
|
||||
const onDefault = currentPath === expectedDefaultPath;
|
||||
const onDefault = currentPath === ".fusion/HEARTBEAT.md";
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
setIsUpgrading(true);
|
||||
@@ -2579,10 +2578,8 @@ function HeartbeatProcedureSection({
|
||||
)}
|
||||
</button>
|
||||
<span className="config-hint">
|
||||
Sets <code>heartbeatProcedurePath</code> to{" "}
|
||||
<code>{expectedDefaultPath}</code>
|
||||
Sets <code>heartbeatProcedurePath</code> to <code>.fusion/HEARTBEAT.md</code>
|
||||
{" "}and seeds the file from the built-in template if it doesn't exist.
|
||||
Each agent gets its own per-agent file, so edits stay scoped to this agent.
|
||||
Operator edits to the file are preserved.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -871,12 +871,9 @@ export function QuickChatFAB({
|
||||
);
|
||||
if (hasDefaultModel) {
|
||||
setSelectedModel(defaultSelection);
|
||||
// Switch to model mode regardless of whether agents are present —
|
||||
// a configured default model is an explicit user preference and
|
||||
// should drive the panel to its corresponding mode immediately,
|
||||
// otherwise the tag/dropdown auto-selection would be invisible
|
||||
// until the user manually toggles modes.
|
||||
setChatMode("model");
|
||||
if (agents.length === 0) {
|
||||
setChatMode("model");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1551,7 +1548,7 @@ export function QuickChatFAB({
|
||||
<div className="quick-chat-panel-header">
|
||||
<div className="quick-chat-panel-title-wrap">
|
||||
<h3>Quick Chat</h3>
|
||||
{chatMode === "model" && selectedModelTag && (
|
||||
{selectedModelTag && (
|
||||
<span className="quick-chat-model-tag" data-testid="quick-chat-model-tag" title={selectedModelTag}>
|
||||
{selectedModelTag}
|
||||
</span>
|
||||
|
||||
@@ -773,10 +773,9 @@ describe("QuickChatFAB", () => {
|
||||
const trigger = screen.getByRole("button", { name: "Select model override" });
|
||||
fireEvent.click(trigger);
|
||||
|
||||
// Find a model option and click it. Scope to role="option" because the
|
||||
// selected model name also renders as a tag in the panel header in model
|
||||
// mode, so a plain text query would match both.
|
||||
const option = await screen.findByRole("option", { name: /Claude Sonnet 4\.5/ });
|
||||
// Find a model option and click it
|
||||
const optionLabel = await screen.findByText("Claude Sonnet 4.5");
|
||||
const option = optionLabel.closest('[role="option"]') ?? optionLabel;
|
||||
fireEvent.click(option);
|
||||
|
||||
// Panel should still be visible after selecting the model
|
||||
|
||||
@@ -840,6 +840,29 @@ describe("TaskCard", () => {
|
||||
expect(timer?.getAttribute("title")).toContain("In progress 5m");
|
||||
});
|
||||
|
||||
it("prefers executionStartedAt over a newer columnMovedAt for in-progress timers", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-25T12:10:00.000Z"));
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "in-progress",
|
||||
columnMovedAt: "2026-04-25T12:08:00.000Z",
|
||||
executionStartedAt: "2026-04-25T12:00:00.000Z",
|
||||
updatedAt: "2026-04-25T12:08:00.000Z",
|
||||
createdAt: "2026-04-25T11:58:00.000Z",
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const timer = container.querySelector(".card-time-indicator");
|
||||
expect(timer?.textContent).toContain("10m");
|
||||
expect(timer?.getAttribute("title")).toContain("In progress 10m");
|
||||
});
|
||||
|
||||
it("does not render timer chip on done card without instrumentation, even with old timestamps", () => {
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
|
||||
@@ -334,6 +334,78 @@ describe("useTasks", () => {
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("preserves stable execution metadata during sparse same-column updates", async () => {
|
||||
const initialTask = createMockTask({
|
||||
id: "FN-001",
|
||||
column: "in-progress" as Column,
|
||||
title: "Initial title",
|
||||
status: "planning",
|
||||
columnMovedAt: "2026-01-02T00:00:00Z",
|
||||
executionStartedAt: "2026-01-01T23:50:00Z",
|
||||
worktree: "/tmp/fn-001",
|
||||
modifiedFiles: ["packages/dashboard/app/components/QuickChatFAB.tsx"],
|
||||
timedExecutionMs: 120_000,
|
||||
workflowStepResults: [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
workflowStepName: "Verify",
|
||||
phase: "pre-merge",
|
||||
status: "pending",
|
||||
startedAt: "2026-01-02T00:00:00Z",
|
||||
},
|
||||
],
|
||||
tokenUsage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
cachedTokens: 10,
|
||||
totalTokens: 150,
|
||||
firstUsedAt: "2026-01-02T00:00:00Z",
|
||||
lastUsedAt: "2026-01-02T00:01:00Z",
|
||||
},
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].status).toBe("planning");
|
||||
});
|
||||
|
||||
const sparseUpdate = {
|
||||
...createMockTask({
|
||||
id: "FN-001",
|
||||
column: "in-progress" as Column,
|
||||
title: "Updated title",
|
||||
status: "executing",
|
||||
updatedAt: "2026-01-03T00:00:00Z",
|
||||
}),
|
||||
columnMovedAt: undefined,
|
||||
executionStartedAt: undefined,
|
||||
worktree: undefined,
|
||||
modifiedFiles: undefined,
|
||||
timedExecutionMs: undefined,
|
||||
workflowStepResults: undefined,
|
||||
tokenUsage: undefined,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
MockEventSource.instances[0]._emit("task:updated", sparseUpdate);
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].title).toBe("Updated title");
|
||||
expect(result.current.tasks[0].status).toBe("executing");
|
||||
expect(result.current.tasks[0].columnMovedAt).toBe("2026-01-02T00:00:00Z");
|
||||
expect(result.current.tasks[0].executionStartedAt).toBe("2026-01-01T23:50:00Z");
|
||||
expect(result.current.tasks[0].worktree).toBe("/tmp/fn-001");
|
||||
expect(result.current.tasks[0].modifiedFiles).toEqual([
|
||||
"packages/dashboard/app/components/QuickChatFAB.tsx",
|
||||
]);
|
||||
expect(result.current.tasks[0].timedExecutionMs).toBe(120_000);
|
||||
expect(result.current.tasks[0].workflowStepResults).toHaveLength(1);
|
||||
expect(result.current.tasks[0].tokenUsage?.totalTokens).toBe(150);
|
||||
});
|
||||
|
||||
it("does not overwrite newer column with stale data (timestamp comparison)", async () => {
|
||||
// Start with task in in-progress
|
||||
const initialTask = createMockTask({
|
||||
|
||||
@@ -25,6 +25,23 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function mergeSameColumnTask(current: Task, incoming: Task): Task {
|
||||
return {
|
||||
...incoming,
|
||||
// Preserve stable execution metadata when a same-column live update arrives
|
||||
// without the full task payload (common during status/log-only SSE updates).
|
||||
columnMovedAt: current.columnMovedAt ?? incoming.columnMovedAt,
|
||||
executionStartedAt: current.executionStartedAt ?? incoming.executionStartedAt,
|
||||
executionCompletedAt: current.executionCompletedAt ?? incoming.executionCompletedAt,
|
||||
worktree: incoming.worktree ?? current.worktree,
|
||||
modifiedFiles: incoming.modifiedFiles ?? current.modifiedFiles,
|
||||
timedExecutionMs: incoming.timedExecutionMs ?? current.timedExecutionMs,
|
||||
workflowStepResults: incoming.workflowStepResults ?? current.workflowStepResults,
|
||||
tokenUsage: incoming.tokenUsage ?? current.tokenUsage,
|
||||
mergeDetails: incoming.mergeDetails ?? current.mergeDetails,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeIncomingTask(current: Task, incoming: Task): Task {
|
||||
const updatedAtCompare = compareTimestamps(incoming.updatedAt, current.updatedAt);
|
||||
if (updatedAtCompare < 0) {
|
||||
@@ -32,7 +49,7 @@ function mergeIncomingTask(current: Task, incoming: Task): Task {
|
||||
}
|
||||
|
||||
if (current.column === incoming.column) {
|
||||
return incoming;
|
||||
return mergeSameColumnTask(current, incoming);
|
||||
}
|
||||
|
||||
const columnTimestampCompare = compareTimestamps(current.columnMovedAt, incoming.columnMovedAt);
|
||||
|
||||
@@ -50,9 +50,7 @@ vi.mock("@fusion/core", () => {
|
||||
parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args),
|
||||
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
|
||||
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
||||
DEFAULT_HEARTBEAT_PROCEDURE_PATH: ".fusion/HEARTBEAT.md",
|
||||
getDefaultHeartbeatProcedurePath: (agentId: string) =>
|
||||
`.fusion/agents/${agentId}/HEARTBEAT.md`,
|
||||
DEFAULT_HEARTBEAT_PROCEDURE_PATH: ".fusion/agents/heartbeat-procedure.md",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
|
||||
import { getDefaultHeartbeatProcedurePath } from "@fusion/core";
|
||||
import { DEFAULT_HEARTBEAT_PROCEDURE_PATH } from "@fusion/core";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
@@ -156,14 +156,12 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
});
|
||||
|
||||
// Seed the default heartbeat procedure file if the new agent landed on
|
||||
// the per-agent default path (which createAgent fills in for
|
||||
// non-ephemeral agents when no override is provided). Idempotent —
|
||||
// operator edits are kept.
|
||||
const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id);
|
||||
if (agent.heartbeatProcedurePath === expectedDefaultPath) {
|
||||
// the default path (which createAgent fills in for non-ephemeral agents
|
||||
// when no override is provided). Idempotent — operator edits are kept.
|
||||
if (agent.heartbeatProcedurePath === DEFAULT_HEARTBEAT_PROCEDURE_PATH) {
|
||||
try {
|
||||
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
|
||||
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), expectedDefaultPath, HEARTBEAT_PROCEDURE);
|
||||
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), DEFAULT_HEARTBEAT_PROCEDURE_PATH, HEARTBEAT_PROCEDURE);
|
||||
} catch {
|
||||
// Non-fatal — the heartbeat resolver falls back to the in-memory constant.
|
||||
}
|
||||
@@ -472,11 +470,10 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/upgrade-heartbeat-procedure
|
||||
* Backfill an existing agent onto the per-agent default heartbeat
|
||||
* procedure file. Sets `heartbeatProcedurePath` to the agent's own
|
||||
* `.fusion/agents/<id>/HEARTBEAT.md` and seeds the file with the
|
||||
* built-in HEARTBEAT_PROCEDURE if it doesn't exist. Idempotent: existing
|
||||
* operator edits to the file are preserved.
|
||||
* Backfill an existing agent onto the default heartbeat procedure file.
|
||||
* Sets `heartbeatProcedurePath` to DEFAULT_HEARTBEAT_PROCEDURE_PATH and
|
||||
* seeds the file with the built-in HEARTBEAT_PROCEDURE if it doesn't exist.
|
||||
* Idempotent: existing operator edits to the file are preserved.
|
||||
*/
|
||||
router.post("/agents/:id/upgrade-heartbeat-procedure", async (req, res) => {
|
||||
try {
|
||||
@@ -490,21 +487,20 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
||||
throw notFound(`agent ${req.params.id} not found`);
|
||||
}
|
||||
|
||||
const targetPath = getDefaultHeartbeatProcedurePath(req.params.id);
|
||||
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
|
||||
const filePath = await ensureDefaultHeartbeatProcedureFile(
|
||||
scopedStore.getRootDir(),
|
||||
targetPath,
|
||||
DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
);
|
||||
|
||||
const updated = await agentStore.updateAgent(req.params.id, {
|
||||
heartbeatProcedurePath: targetPath,
|
||||
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
});
|
||||
|
||||
res.json({
|
||||
agent: updated,
|
||||
heartbeatProcedurePath: targetPath,
|
||||
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
procedureFileSeeded: filePath !== null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user