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:
gsxdsm
2026-04-09 13:00:37 -07:00
parent a53da8890c
commit b0f543b632
8 changed files with 501 additions and 2 deletions

View 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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