feat(FN-1381): add Send Back dropdown for in-progress tasks
- Add Send Back dropdown to TaskCard showing for in-progress tasks with available target columns - Thread onMoveTask prop through Column component to BoardView - Terminate agent sessions when tasks move away from in-progress column - Add executor tests for move-away session termination - Add TaskCard send-back UI tests with dropdown visibility and interaction verification
This commit is contained in:
5
.changeset/add-send-back-in-progress.md
Normal file
5
.changeset/add-send-back-in-progress.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Allow in-progress tasks to be sent back to Todo or Triage directly from the board card view. The executor also terminates active agent sessions when tasks are moved away from in-progress, preventing zombie sessions.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
- `TaskExecutor` terminates active agent sessions (single and step) when tasks are moved away from `in-progress` via the `task:moved` event handler. This prevents zombie sessions when users manually send tasks back to todo/triage from the board UI.
|
||||||
- Agent preset templates in `NewAgentDialog.tsx` are a UI-only concept (`AgentPreset` interface), separate from the engine's `AgentPromptTemplate` type. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types.
|
- Agent preset templates in `NewAgentDialog.tsx` are a UI-only concept (`AgentPreset` interface), separate from the engine's `AgentPromptTemplate` type. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types.
|
||||||
- `soul` and `instructionsText` are already supported in `AgentCreateInput` and `AgentUpdateInput` — no API changes needed when adding these to presets.
|
- `soul` and `instructionsText` are already supported in `AgentCreateInput` and `AgentUpdateInput` — no API changes needed when adding these to presets.
|
||||||
- `CronRunner` uses dependency injection for AI prompt execution: an `AiPromptExecutor` function is injected via options. This keeps it decoupled from `createKbAgent` and testable without real agent sessions.
|
- `CronRunner` uses dependency injection for AI prompt execution: an `AiPromptExecutor` function is injected via options. This keeps it decoupled from `createKbAgent` and testable without real agent sessions.
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
onOpenMission={onOpenMission}
|
onOpenMission={onOpenMission}
|
||||||
|
onMoveTask={onMoveTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot } from "lucide-react";
|
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot } from "lucide-react";
|
||||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
|
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
|
||||||
|
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core";
|
||||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||||
import { GitHubBadge } from "./GitHubBadge";
|
import { GitHubBadge } from "./GitHubBadge";
|
||||||
import { pickPreferredBadge } from "./TaskCardBadge";
|
import { pickPreferredBadge } from "./TaskCardBadge";
|
||||||
@@ -91,6 +92,8 @@ interface TaskCardProps {
|
|||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
/** Called when user clicks the mission badge on a task card. */
|
/** Called when user clicks the mission badge on a task card. */
|
||||||
onOpenMission?: (missionId: string) => void;
|
onOpenMission?: (missionId: string) => void;
|
||||||
|
/** Called when user moves a task to a different column from the card. */
|
||||||
|
onMoveTask?: (id: string, column: Column) => Promise<Task>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function areTaskBadgeInfosEqual(
|
function areTaskBadgeInfosEqual(
|
||||||
@@ -136,6 +139,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
||||||
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
||||||
previous.onOpenMission === next.onOpenMission &&
|
previous.onOpenMission === next.onOpenMission &&
|
||||||
|
previous.onMoveTask === next.onMoveTask &&
|
||||||
previousTask.id === nextTask.id &&
|
previousTask.id === nextTask.id &&
|
||||||
previousTask.title === nextTask.title &&
|
previousTask.title === nextTask.title &&
|
||||||
previousTask.description === nextTask.description &&
|
previousTask.description === nextTask.description &&
|
||||||
@@ -182,6 +186,7 @@ function TaskCardComponent({
|
|||||||
onOpenDetailWithTab,
|
onOpenDetailWithTab,
|
||||||
taskStuckTimeoutMs,
|
taskStuckTimeoutMs,
|
||||||
onOpenMission,
|
onOpenMission,
|
||||||
|
onMoveTask,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [fileDragOver, setFileDragOver] = useState(false);
|
const [fileDragOver, setFileDragOver] = useState(false);
|
||||||
@@ -194,10 +199,12 @@ function TaskCardComponent({
|
|||||||
);
|
);
|
||||||
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
||||||
const [agentName, setAgentName] = useState<string | null>(null);
|
const [agentName, setAgentName] = useState<string | null>(null);
|
||||||
|
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
||||||
|
|
||||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const touchOpenHandledRef = useRef(false);
|
const touchOpenHandledRef = useRef(false);
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sendBackRef = useRef<HTMLDivElement>(null);
|
||||||
const [isInViewport, setIsInViewport] = useState(false);
|
const [isInViewport, setIsInViewport] = useState(false);
|
||||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
|
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
|
||||||
|
|
||||||
@@ -215,6 +222,18 @@ function TaskCardComponent({
|
|||||||
setEditDescription(task.description || "");
|
setEditDescription(task.description || "");
|
||||||
}, [task.id, task.description]);
|
}, [task.id, task.description]);
|
||||||
|
|
||||||
|
// Close send-back menu on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSendBackMenu) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (sendBackRef.current && !sendBackRef.current.contains(e.target as Node)) {
|
||||||
|
setShowSendBackMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("click", handleClick);
|
||||||
|
return () => document.removeEventListener("click", handleClick);
|
||||||
|
}, [showSendBackMenu]);
|
||||||
|
|
||||||
// Fetch mission title when missionId is set
|
// Fetch mission title when missionId is set
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!task.missionId) {
|
if (!task.missionId) {
|
||||||
@@ -603,6 +622,23 @@ function TaskCardComponent({
|
|||||||
}
|
}
|
||||||
}, [task.missionId, onOpenMission]);
|
}, [task.missionId, onOpenMission]);
|
||||||
|
|
||||||
|
const handleSendBackClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowSendBackMenu((current) => !current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSendBackOptionClick = useCallback((e: React.MouseEvent, column: Column) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowSendBackMenu(false);
|
||||||
|
if (!onMoveTask) return;
|
||||||
|
|
||||||
|
void onMoveTask(task.id, column).then(() => {
|
||||||
|
addToast(`Moved ${task.id} to ${COLUMN_LABELS[column]}`, "success");
|
||||||
|
}).catch((err: any) => {
|
||||||
|
addToast(`Failed to move ${task.id}: ${err.message}`, "error");
|
||||||
|
});
|
||||||
|
}, [addToast, onMoveTask, task.id]);
|
||||||
|
|
||||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -729,6 +765,37 @@ function TaskCardComponent({
|
|||||||
Unarchive
|
Unarchive
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{task.column === "in-progress" && onMoveTask && (
|
||||||
|
<div className="card-send-back" ref={sendBackRef}>
|
||||||
|
<button
|
||||||
|
className="card-send-back-btn"
|
||||||
|
onClick={handleSendBackClick}
|
||||||
|
title="Send back"
|
||||||
|
aria-label="Send back"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={showSendBackMenu}
|
||||||
|
>
|
||||||
|
Send back
|
||||||
|
<ChevronDown size={10} />
|
||||||
|
</button>
|
||||||
|
{showSendBackMenu && (
|
||||||
|
<div className="card-send-back-menu" role="menu">
|
||||||
|
{VALID_TRANSITIONS["in-progress"]
|
||||||
|
.filter((col) => col !== "in-review")
|
||||||
|
.map((col) => (
|
||||||
|
<button
|
||||||
|
key={col}
|
||||||
|
className="card-send-back-menu-item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={(e) => handleSendBackOptionClick(e, col)}
|
||||||
|
>
|
||||||
|
{COLUMN_LABELS[col]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{task.size && (
|
{task.size && (
|
||||||
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
|
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
|
||||||
{task.size}
|
{task.size}
|
||||||
|
|||||||
@@ -3488,3 +3488,143 @@ describe("TaskCard agent badge", () => {
|
|||||||
expect(screen.queryByTestId("bot-icon")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("bot-icon")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TaskCard send-back functionality", () => {
|
||||||
|
const createTask = (overrides: Partial<Task> = {}): Task => ({
|
||||||
|
id: "FN-001",
|
||||||
|
description: "Test task",
|
||||||
|
column: "todo",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||||
|
...overrides,
|
||||||
|
} as Task);
|
||||||
|
|
||||||
|
it("renders send-back button when task is in-progress and onMoveTask is provided", () => {
|
||||||
|
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||||
|
const task = createTask({ column: "in-progress" });
|
||||||
|
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /send back/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render send-back button when task is in todo", () => {
|
||||||
|
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||||
|
const task = createTask({ column: "todo" });
|
||||||
|
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /send back/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render send-back button when task is in in-review", () => {
|
||||||
|
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||||
|
const task = createTask({ column: "in-review" });
|
||||||
|
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /send back/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render send-back button when onMoveTask is not provided", () => {
|
||||||
|
const task = createTask({ column: "in-progress" });
|
||||||
|
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /send back/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles dropdown menu when send-back button is clicked", () => {
|
||||||
|
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||||
|
const task = createTask({ column: "in-progress" });
|
||||||
|
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||||
|
|
||||||
|
// Initially, dropdown is not visible
|
||||||
|
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click the send-back button
|
||||||
|
const btn = screen.getByRole("button", { name: /send back/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
// Dropdown should now be visible
|
||||||
|
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dropdown shows Todo and Triage options but not In Review", () => {
|
||||||
|
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||||
|
const task = createTask({ column: "in-progress" });
|
||||||
|
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||||
|
|
||||||
|
// Open the dropdown
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
|
||||||
|
|
||||||
|
// Check menu is visible
|
||||||
|
const menu = screen.getByRole("menu");
|
||||||
|
expect(menu).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should show Todo and Triage
|
||||||
|
expect(screen.getByRole("menuitem", { name: /todo/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("menuitem", { name: /triage/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show In Review
|
||||||
|
expect(screen.queryByRole("menuitem", { name: /in review/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a dropdown option calls onMoveTask with correct column and closes menu", async () => {
|
||||||
|
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||||
|
const addToast = vi.fn();
|
||||||
|
const task = createTask({ column: "in-progress" });
|
||||||
|
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={addToast} onMoveTask={onMoveTask} />);
|
||||||
|
|
||||||
|
// Open the dropdown
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
|
||||||
|
|
||||||
|
// Click "Todo" option
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /todo/i }));
|
||||||
|
|
||||||
|
// Should have called onMoveTask
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dropdown should be closed
|
||||||
|
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Toast should have been shown
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Moved FN-001 to Todo", "success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast when onMoveTask fails", async () => {
|
||||||
|
const onMoveTask = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||||
|
const addToast = vi.fn();
|
||||||
|
const task = createTask({ column: "in-progress" });
|
||||||
|
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={addToast} onMoveTask={onMoveTask} />);
|
||||||
|
|
||||||
|
// Open the dropdown and click "Triage"
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /triage/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Failed to move FN-001"), "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking outside dropdown closes it", () => {
|
||||||
|
const onMoveTask = vi.fn().mockResolvedValue({});
|
||||||
|
const task = createTask({ column: "in-progress" });
|
||||||
|
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} onMoveTask={onMoveTask} />);
|
||||||
|
|
||||||
|
// Open the dropdown
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
|
||||||
|
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click outside (on the card itself, not inside the send-back dropdown)
|
||||||
|
fireEvent.click(document.querySelector(".card")!);
|
||||||
|
|
||||||
|
// Dropdown should be closed
|
||||||
|
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4968,6 +4968,80 @@ body {
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Send Back button and dropdown */
|
||||||
|
.card-send-back {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-send-back-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .card-send-back-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-send-back-btn:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-send-back-btn:focus {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 1px solid var(--todo);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-send-back-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 100px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-send-back-menu-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-send-back-menu-item:hover {
|
||||||
|
background: var(--surface-hover, rgba(0, 0, 0, 0.04));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-send-back-menu-item:focus {
|
||||||
|
outline: none;
|
||||||
|
background: var(--surface-hover, rgba(0, 0, 0, 0.04));
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading state during save */
|
/* Loading state during save */
|
||||||
.card-edit-loading {
|
.card-edit-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -8233,6 +8233,197 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
|
|||||||
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
|
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("when task is moved away from in-progress", () => {
|
||||||
|
it("terminates active session and removes from activeSessions map", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
|
||||||
|
const disposeSpy = vi.fn();
|
||||||
|
const mockSession = {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: disposeSpy,
|
||||||
|
steer: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate an active session
|
||||||
|
(executor as any).activeSessions.set("FN-001", {
|
||||||
|
session: mockSession,
|
||||||
|
seenSteeringIds: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = {
|
||||||
|
id: "FN-001",
|
||||||
|
title: "Test Task",
|
||||||
|
description: "Test",
|
||||||
|
column: "todo" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger task:moved away from in-progress
|
||||||
|
store._trigger("task:moved", { task, from: "in-progress", to: "todo" });
|
||||||
|
|
||||||
|
// Allow async handlers to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Verify session was disposed and removed from map
|
||||||
|
expect(disposeSpy).toHaveBeenCalled();
|
||||||
|
expect((executor as any).activeSessions.has("FN-001")).toBe(false);
|
||||||
|
// Verify task was added to pausedAborted set
|
||||||
|
expect((executor as any).pausedAborted.has("FN-001")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates active step executor when task is moved away", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
|
||||||
|
const mockTerminateAllSessions = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockStepExecutor = {
|
||||||
|
executeAll: vi.fn().mockResolvedValue([]),
|
||||||
|
terminateAllSessions: mockTerminateAllSessions,
|
||||||
|
cleanup: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate an active step executor
|
||||||
|
(executor as any).activeStepExecutors.set("FN-002", mockStepExecutor as any);
|
||||||
|
|
||||||
|
const task = {
|
||||||
|
id: "FN-002",
|
||||||
|
title: "Test Task",
|
||||||
|
description: "Test",
|
||||||
|
column: "todo" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger task:moved away from in-progress
|
||||||
|
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
|
||||||
|
|
||||||
|
// Allow async handlers to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Verify terminateAllSessions was called
|
||||||
|
expect(mockTerminateAllSessions).toHaveBeenCalled();
|
||||||
|
// Verify removed from map
|
||||||
|
expect((executor as any).activeStepExecutors.has("FN-002")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles graceful no-op when no active session exists", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
|
||||||
|
// No active session set
|
||||||
|
|
||||||
|
const task = {
|
||||||
|
id: "FN-003",
|
||||||
|
title: "Test Task",
|
||||||
|
description: "Test",
|
||||||
|
column: "todo" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => {
|
||||||
|
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("untracks task from stuck detector when moved away", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const untrackSpy = vi.fn();
|
||||||
|
const stuckDetector = {
|
||||||
|
trackTask: vi.fn(),
|
||||||
|
recordActivity: vi.fn(),
|
||||||
|
recordProgress: vi.fn(),
|
||||||
|
untrackTask: untrackSpy,
|
||||||
|
};
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", {
|
||||||
|
stuckTaskDetector: stuckDetector as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const disposeSpy = vi.fn();
|
||||||
|
const mockSession = {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: disposeSpy,
|
||||||
|
steer: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(executor as any).activeSessions.set("FN-004", {
|
||||||
|
session: mockSession,
|
||||||
|
seenSteeringIds: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = {
|
||||||
|
id: "FN-004",
|
||||||
|
title: "Test Task",
|
||||||
|
description: "Test",
|
||||||
|
column: "todo" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store._trigger("task:moved", { task, from: "in-progress", to: "todo" });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(untrackSpy).toHaveBeenCalledWith("FN-004");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds task to pausedAborted set to prevent re-execution", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
|
||||||
|
const disposeSpy = vi.fn();
|
||||||
|
const mockSession = {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: disposeSpy,
|
||||||
|
steer: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(executor as any).activeSessions.set("FN-005", {
|
||||||
|
session: mockSession,
|
||||||
|
seenSteeringIds: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = {
|
||||||
|
id: "FN-005",
|
||||||
|
title: "Test Task",
|
||||||
|
description: "Test",
|
||||||
|
column: "triage" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store._trigger("task:moved", { task, from: "in-progress", to: "triage" });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect((executor as any).pausedAborted.has("FN-005")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("tracks task with stuck detector after session creation", async () => {
|
it("tracks task with stuck detector after session creation", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const stuckDetector = {
|
const stuckDetector = {
|
||||||
|
|||||||
@@ -327,13 +327,33 @@ export class TaskExecutor {
|
|||||||
) {
|
) {
|
||||||
executorLog.log(`TaskExecutor constructed (rootDir=${rootDir}, hasSemaphore=${!!options.semaphore}, hasStuckDetector=${!!options.stuckTaskDetector})`);
|
executorLog.log(`TaskExecutor constructed (rootDir=${rootDir}, hasSemaphore=${!!options.semaphore}, hasStuckDetector=${!!options.stuckTaskDetector})`);
|
||||||
|
|
||||||
store.on("task:moved", ({ task, to }) => {
|
store.on("task:moved", ({ task, from, to }) => {
|
||||||
executorLog.log(`[event:task:moved] ${task.id} → ${to}`);
|
executorLog.log(`[event:task:moved] ${task.id}: ${from} → ${to}`);
|
||||||
if (to === "in-progress") {
|
if (to === "in-progress") {
|
||||||
executorLog.log(`[event:task:moved] Initiating execute() for ${task.id}`);
|
executorLog.log(`[event:task:moved] Initiating execute() for ${task.id}`);
|
||||||
this.execute(task).catch((err) =>
|
this.execute(task).catch((err) =>
|
||||||
executorLog.error(`Failed to start ${task.id}:`, err),
|
executorLog.error(`Failed to start ${task.id}:`, err),
|
||||||
);
|
);
|
||||||
|
} else if (from === "in-progress") {
|
||||||
|
// Task moved away from in-progress — terminate any active sessions
|
||||||
|
if (this.activeSessions.has(task.id)) {
|
||||||
|
executorLog.log(`${task.id} moved from in-progress to ${to} — terminating agent session`);
|
||||||
|
this.pausedAborted.add(task.id);
|
||||||
|
this.options.stuckTaskDetector?.untrackTask(task.id);
|
||||||
|
const { session } = this.activeSessions.get(task.id)!;
|
||||||
|
session.dispose();
|
||||||
|
this.activeSessions.delete(task.id);
|
||||||
|
}
|
||||||
|
if (this.activeStepExecutors.has(task.id)) {
|
||||||
|
executorLog.log(`${task.id} moved from in-progress to ${to} — terminating step sessions`);
|
||||||
|
this.pausedAborted.add(task.id);
|
||||||
|
this.options.stuckTaskDetector?.untrackTask(task.id);
|
||||||
|
const stepExecutor = this.activeStepExecutors.get(task.id)!;
|
||||||
|
stepExecutor.terminateAllSessions().catch((err) =>
|
||||||
|
executorLog.error(`Failed to terminate step sessions for ${task.id}:`, err),
|
||||||
|
);
|
||||||
|
this.activeStepExecutors.delete(task.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user