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