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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
};
});

View File

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