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
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
@@ -253,6 +253,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
onMoveTask={onMoveTask}
|
||||
/>
|
||||
))}
|
||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot } from "lucide-react";
|
||||
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 { GitHubBadge } from "./GitHubBadge";
|
||||
import { pickPreferredBadge } from "./TaskCardBadge";
|
||||
@@ -91,6 +92,8 @@ interface TaskCardProps {
|
||||
taskStuckTimeoutMs?: number;
|
||||
/** Called when user clicks the mission badge on a task card. */
|
||||
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(
|
||||
@@ -136,6 +139,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
||||
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
||||
previous.onOpenMission === next.onOpenMission &&
|
||||
previous.onMoveTask === next.onMoveTask &&
|
||||
previousTask.id === nextTask.id &&
|
||||
previousTask.title === nextTask.title &&
|
||||
previousTask.description === nextTask.description &&
|
||||
@@ -182,6 +186,7 @@ function TaskCardComponent({
|
||||
onOpenDetailWithTab,
|
||||
taskStuckTimeoutMs,
|
||||
onOpenMission,
|
||||
onMoveTask,
|
||||
}: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
@@ -194,10 +199,12 @@ function TaskCardComponent({
|
||||
);
|
||||
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
||||
const [agentName, setAgentName] = useState<string | null>(null);
|
||||
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
||||
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const touchOpenHandledRef = useRef(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const sendBackRef = useRef<HTMLDivElement>(null);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
|
||||
|
||||
@@ -215,6 +222,18 @@ function TaskCardComponent({
|
||||
setEditDescription(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
|
||||
useEffect(() => {
|
||||
if (!task.missionId) {
|
||||
@@ -603,6 +622,23 @@ function TaskCardComponent({
|
||||
}
|
||||
}, [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" : ""}`;
|
||||
|
||||
if (isEditing) {
|
||||
@@ -729,6 +765,37 @@ function TaskCardComponent({
|
||||
Unarchive
|
||||
</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 && (
|
||||
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
|
||||
{task.size}
|
||||
|
||||
@@ -3488,3 +3488,143 @@ describe("TaskCard agent badge", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.card-edit-loading {
|
||||
display: flex;
|
||||
|
||||
@@ -8233,6 +8233,197 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
|
||||
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 () => {
|
||||
const store = createMockStore();
|
||||
const stuckDetector = {
|
||||
|
||||
@@ -327,13 +327,33 @@ export class TaskExecutor {
|
||||
) {
|
||||
executorLog.log(`TaskExecutor constructed (rootDir=${rootDir}, hasSemaphore=${!!options.semaphore}, hasStuckDetector=${!!options.stuckTaskDetector})`);
|
||||
|
||||
store.on("task:moved", ({ task, to }) => {
|
||||
executorLog.log(`[event:task:moved] ${task.id} → ${to}`);
|
||||
store.on("task:moved", ({ task, from, to }) => {
|
||||
executorLog.log(`[event:task:moved] ${task.id}: ${from} → ${to}`);
|
||||
if (to === "in-progress") {
|
||||
executorLog.log(`[event:task:moved] Initiating execute() for ${task.id}`);
|
||||
this.execute(task).catch((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