feat(FN-2289): merge fusion/fn-2289
This commit is contained in:
@@ -1157,17 +1157,19 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
|
||||
{/* Thread */}
|
||||
<div className="chat-thread">
|
||||
{/* Header */}
|
||||
<div className="chat-thread-header">
|
||||
{isMobile && (
|
||||
<button className="btn-icon" onClick={handleBack} data-testid="chat-back-btn">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
<Bot size={16} />
|
||||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</div>
|
||||
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */}
|
||||
{(activeSession || !isMobile) && (
|
||||
<div className="chat-thread-header">
|
||||
{isMobile && activeSession && (
|
||||
<button className="btn-icon" onClick={handleBack} data-testid="chat-back-btn">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
<Bot size={16} />
|
||||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="chat-messages" ref={messagesContainerRef}>
|
||||
|
||||
@@ -1744,6 +1744,149 @@ describe("ChatView sidebar structure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView mobile behavior", () => {
|
||||
function ensureMatchMedia() {
|
||||
if (!window.matchMedia) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mockMobileViewport() {
|
||||
ensureMatchMedia();
|
||||
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
||||
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: query === "(max-width: 768px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function mockDesktopViewport() {
|
||||
ensureMatchMedia();
|
||||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||||
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
it("mobile mode: does not render thread header when no active session (list view)", () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
try {
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
activeSession: null,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
// Thread header should not be rendered when there's no active session
|
||||
expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument();
|
||||
// Back button should not be visible
|
||||
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
restoreMatchMedia();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: renders thread header with back button when session is active", () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
// Thread header should be rendered when there's an active session
|
||||
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
|
||||
// Back button should be visible in mobile thread view
|
||||
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
|
||||
} finally {
|
||||
restoreMatchMedia();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: tapping back button calls selectSession with empty string to return to list", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const selectSession = vi.fn();
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
selectSession,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const backBtn = screen.getByTestId("chat-back-btn");
|
||||
await userEvent.click(backBtn);
|
||||
|
||||
// Back button should trigger selectSession("") to return to list view
|
||||
expect(selectSession).toHaveBeenCalledWith("");
|
||||
} finally {
|
||||
restoreMatchMedia();
|
||||
}
|
||||
});
|
||||
|
||||
it("desktop mode: renders thread header even without active session (shows empty state)", () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
try {
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
activeSession: null,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
// Desktop mode: thread header should always be visible (even in empty state)
|
||||
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
|
||||
// Back button should not be visible in desktop mode
|
||||
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
|
||||
// Should show empty state
|
||||
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
|
||||
} finally {
|
||||
restoreMatchMedia();
|
||||
}
|
||||
});
|
||||
|
||||
it("desktop mode: thread header is visible with active session", () => {
|
||||
const restoreMatchMedia = mockDesktopViewport();
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
// Desktop mode: thread header should always be visible
|
||||
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument();
|
||||
// Back button should not be visible in desktop mode
|
||||
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
restoreMatchMedia();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView mobile CSS contract", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
|
||||
|
||||
@@ -4465,6 +4465,333 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── FN-2289 Regression: Idle-state timer persistence ─────────────────────────────────────
|
||||
// These tests verify the fix for the defect where agent timers were unintentionally cleared
|
||||
// when agents transitioned to "idle" state. The isTickableState() function must include "idle"
|
||||
// as a valid state so that timers remain armed for agents between tasks.
|
||||
describe("FN-2289: idle-state timer persistence", () => {
|
||||
let eventStore: EventEmitter & {
|
||||
getAgent: ReturnType<typeof vi.fn>;
|
||||
getActiveHeartbeatRun: ReturnType<typeof vi.fn>;
|
||||
getBudgetStatus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
eventStore = Object.assign(new EventEmitter(), {
|
||||
getAgent: vi.fn().mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "active" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
})),
|
||||
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
|
||||
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
|
||||
});
|
||||
|
||||
scheduler = new HeartbeatTriggerScheduler(eventStore as unknown as AgentStore, callback);
|
||||
scheduler.start();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler?.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("timer remains armed when agent transitions to idle state (regression test for FN-2289)", async () => {
|
||||
// Register agent with active state
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Simulate agent transitioning to idle state
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "idle" as const, // Agent is now idle
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Timer should still be registered
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Timer should fire for idle agent
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "scheduled",
|
||||
intervalMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it("timer fires correctly for idle agent at scheduled interval", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Update agent to idle state
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "idle" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Timer should still be armed
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Advance time and verify multiple fires
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("timer fires for agent transitioning from idle back to active", async () => {
|
||||
// Start with idle state
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "idle" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
|
||||
// Timer should be armed even for idle agent
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Advance time - timer should fire
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("idle agent receives timer trigger with correct context", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 15000 });
|
||||
|
||||
// Update to idle state
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "idle" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith("agent-001", "timer", expect.objectContaining({
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "scheduled",
|
||||
intervalMs: 15000,
|
||||
}));
|
||||
});
|
||||
|
||||
it("timer is still armed after multiple idle state transitions", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
|
||||
|
||||
// First transition to idle
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "idle" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Second transition (still idle, but emit update)
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "idle", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Third transition back to active
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "active" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "active", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Timer should still be registered through all transitions
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Timer should fire
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("timer fires for running agent (pre-existing behavior)", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
|
||||
// Update to running state
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "running" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "running", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("timer is unregistered when agent becomes terminated (should clear timer)", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Update to terminated state
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "terminated" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "terminated", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Timer should be cleared for terminated agents
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("timer is unregistered when agent becomes error state (should clear timer)", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Update to error state
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "error" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "error", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Timer should be cleared for error agents
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("timer is unregistered when agent becomes paused state (should clear timer)", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Update to paused state
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "paused" as const,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}));
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "paused", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Timer should be cleared for paused agents
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── FN-2289 Regression: Multiplier stability across re-registration ─────────────────────
|
||||
describe("FN-2289: multiplier stability across re-registration", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler?.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("multiplier-adjusted interval remains stable when re-registering", async () => {
|
||||
const taskStore = {
|
||||
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 0.5 }),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
|
||||
scheduler.start();
|
||||
|
||||
// First registration with multiplier 0.5 -> effective interval 5000ms
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
|
||||
await vi.advanceTimersByTimeAsync(100); // Allow pending async operations to complete
|
||||
|
||||
// Re-register (simulating settings change or config update)
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Timer should still fire at the multiplied interval (5000ms)
|
||||
// The timer was set up immediately, so we need to ensure we advance past 5000ms total
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("async multiplier registration does not stale-overwrite newer registration", async () => {
|
||||
const taskStore = {
|
||||
getSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 2.0 }),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
scheduler = new HeartbeatTriggerScheduler(store, callback, taskStore);
|
||||
scheduler.start();
|
||||
|
||||
// Register with multiplier 2.0 -> effective interval 20000ms
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
|
||||
await Promise.resolve();
|
||||
|
||||
// Immediately re-register before async multiplier completes
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10000 });
|
||||
|
||||
// Timer should still be registered
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Advance time past the original interval (10s) but before multiplied (20s)
|
||||
// If stale-overwrite happens, callback would be called at 10s instead of 20s
|
||||
await vi.advanceTimersByTimeAsync(15000);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
// Timer should fire at 20s (correct multiplied interval)
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignment watching", () => {
|
||||
let eventStore: EventEmitter & {
|
||||
getActiveHeartbeatRun: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -1667,9 +1667,19 @@ interface AgentTimer {
|
||||
/**
|
||||
* True when an agent's state indicates it should be ticking right now.
|
||||
* Heartbeats track liveness while the agent is meant to be doing work.
|
||||
*
|
||||
* States where timers should remain armed:
|
||||
* - "active" — Agent is working
|
||||
* - "running" — Agent has an active heartbeat run
|
||||
* - "idle" — Agent is between tasks, waiting for work (FN-2289 fix)
|
||||
*
|
||||
* States where timers should be cleared:
|
||||
* - "terminated" — Agent has completed/failed
|
||||
* - "error" — Agent encountered an error
|
||||
* - "paused" — Agent is paused by budget exhaustion or manual action
|
||||
*/
|
||||
function isTickableState(state: Agent["state"]): boolean {
|
||||
return state === "active" || state === "running";
|
||||
return state === "active" || state === "running" || state === "idle";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user