feat(FN-4137): add review markdown toggle to document view (FN-4137)
This large merge delivers substantial improvements across the dashboard, engine, and CI infrastructure. Key themes include GitHub tracking feature parity (mobile layout, card icons, header alignment, title derivation, restart persistence, untitled issue handling), chat/room UX enhancements (draft au Fusion-Task-Id: FN-4137
This commit is contained in:
5
.changeset/FN-4137-review-markdown-toggle.md
Normal file
5
.changeset/FN-4137-review-markdown-toggle.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Task detail Review tab now renders review item bodies as markdown by default, with a Markdown/Plain toggle that persists per user.
|
||||
@@ -47,6 +47,21 @@ The script checks for:
|
||||
|
||||
Treat flagged candidates as recovery leads, not automatic truth: review the surviving task files, logs, and commit history, then file a follow-up recovery task for any confirmed overwrite.
|
||||
|
||||
### Reconciling stale task title/description vs canonical PROMPT.md
|
||||
|
||||
Use the one-shot reconciliation script only when the surviving evidence agrees on a single canonical task identity and the ambiguity is limited to stale metadata fields on that same task row:
|
||||
|
||||
```bash
|
||||
node scripts/reconcile-fn-3909-identity.mjs [--project-root /path/to/project] [--apply]
|
||||
```
|
||||
|
||||
The script is intentionally narrow and idempotent:
|
||||
- dry-run is the default and prints the before/after title + description diff without mutating anything
|
||||
- `--apply` only updates task `FN-3909` through `TaskStore.updateTask(...)` and appends an audit log entry referencing `FN-4194`
|
||||
- the script refuses to run if `PROMPT.md` no longer matches the expected canonical heading, if the stale heartbeat-scope row contents are not present, or if the row is already canonical without the reconciliation marker
|
||||
|
||||
Use this path for the confirmed FN-3909 mismatch (canonical UI-fix prompt/merge history, stale heartbeat-scope title/description). Do **not** use it for allocator-collision or overwrite incidents that may involve multiple tasks or conflicting survivors; run `scripts/audit-task-id-collisions.mjs` first and treat those cases as recovery/postmortem work instead of automatic metadata repair.
|
||||
|
||||
## SQLite write-path lock recovery (FN-4042 / FN-4083)
|
||||
|
||||
- Every disk-backed SQLite connection that Fusion opens for project storage (`fusion.db`), the central registry (`fusion-central.db`), archives (`archive.db`), and worktree hydration explicitly sets `PRAGMA busy_timeout = 5000` and `PRAGMA journal_mode = WAL` at connection open time before write work begins.
|
||||
|
||||
@@ -330,6 +330,7 @@ This file is the contract for execution and review.
|
||||
- Pull-request mode refreshes live GitHub-backed review decision/thread/comment state and updates PR metadata freshness.
|
||||
- Direct/non-PR mode refreshes normalized reviewer-agent feedback from persisted task review artifacts and does not call GitHub.
|
||||
- In direct/non-PR auto-merge mode, the Review tab shows parsed reviewer-agent feedback with explicit loading/error/empty states instead of sending users to raw comments or agent logs.
|
||||
- Review item bodies render markdown by default, and users can switch between **Markdown** and **Plain** modes from the Review tab action bar; the preference persists locally per user.
|
||||
- **Comments remains the general discussion surface**; Review remains the actionable review surface.
|
||||
- **Steering comments** (`fn task steer`) are execution guidance for the running agent.
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.task-review-tab__item-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-review-tab__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
@@ -88,9 +94,28 @@
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
font-family: var(--font-primary);
|
||||
}
|
||||
|
||||
.task-review-tab__body.markdown-body {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-review-tab__body.markdown-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.task-review-tab__body.markdown-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-review-tab__body.markdown-body pre,
|
||||
.task-review-tab__body.markdown-body table {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.task-review-tab__status {
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
@@ -150,10 +175,11 @@
|
||||
|
||||
.task-review-tab__actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-review-tab__actions .btn {
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.task-review-tab__refresh-meta {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import "./TaskReviewTab.css";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems } from "../api";
|
||||
import type { SelectedReviewItem } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -14,6 +17,7 @@ interface Props {
|
||||
|
||||
const REVIEW_LOAD_ERROR_MESSAGE = "Failed to load review data.";
|
||||
const DIRECT_MODE_EMPTY_MESSAGE = "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.";
|
||||
const REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY = "fn-task-review-markdown";
|
||||
|
||||
type ReviewState = NonNullable<TaskDetail["reviewState"]>;
|
||||
type ReviewItem = ReviewState["items"][number];
|
||||
@@ -30,6 +34,54 @@ type DisplayReviewItem = {
|
||||
item?: ReviewItem;
|
||||
};
|
||||
|
||||
function readBooleanPref(key: string, defaultValue: boolean): boolean {
|
||||
if (typeof window === "undefined") return defaultValue;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (raw === null) return defaultValue;
|
||||
return raw === "true";
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeBooleanPref(key: string, value: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(key, value ? "true" : "false");
|
||||
} catch {
|
||||
// ignore storage failures (quota, private mode, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
const markdownComponents: Components = {
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre
|
||||
{...props}
|
||||
style={{
|
||||
overflowX: "auto",
|
||||
maxWidth: "100%",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children, ...props }) => (
|
||||
<table
|
||||
{...props}
|
||||
style={{
|
||||
display: "block",
|
||||
overflowX: "auto",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
};
|
||||
|
||||
function formatTimestamp(value?: string): string {
|
||||
if (!value) return "Never";
|
||||
return new Date(value).toLocaleString();
|
||||
@@ -81,11 +133,16 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
|
||||
const [review, setReview] = useState(task.reviewState ?? null);
|
||||
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(() => readBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, true));
|
||||
|
||||
const canRevise = selected.length > 0 && !revising;
|
||||
const isPrMode = review?.source === "pull-request";
|
||||
const displayItems = useMemo(() => (review ? getDisplayReviewItems(review) : []), [review]);
|
||||
|
||||
useEffect(() => {
|
||||
writeBooleanPref(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown);
|
||||
}, [renderMarkdown]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
@@ -214,6 +271,15 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
{decisionLabel ? <span className={`task-review-tab__decision task-review-tab__decision--${decisionLabel}`}>{decisionLabel}</span> : null}
|
||||
</div>
|
||||
<div className="task-review-tab__actions">
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => setRenderMarkdown((prev) => !prev)}
|
||||
aria-pressed={renderMarkdown}
|
||||
data-testid="task-review-markdown-toggle"
|
||||
title={renderMarkdown ? "Show raw text" : "Show formatted markdown"}
|
||||
>
|
||||
{renderMarkdown ? "Markdown" : "Plain"}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={onRefresh} disabled={refreshing || loading}>{refreshing ? "Refreshing…" : "Refresh"}</button>
|
||||
<button className="btn btn-primary btn-sm" disabled={!canRevise} onClick={onRevise}>{revising ? "Queueing…" : "Request revision"}</button>
|
||||
</div>
|
||||
@@ -227,22 +293,36 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
|
||||
{!loading && !error && !isPrMode && displayItems.length === 0 ? <div className="task-review-tab__empty">{emptyMessage ?? DIRECT_MODE_EMPTY_MESSAGE}</div> : null}
|
||||
{!loading && !error && displayItems.length > 0 ? (
|
||||
<ul className="task-review-tab__list">
|
||||
{displayItems.map((item) => (
|
||||
<li key={item.id} className="task-review-tab__item card">
|
||||
<label className="task-review-tab__direct-item task-review-tab__direct-item--selectable">
|
||||
<div className="task-review-tab__summary-wrap">
|
||||
<input type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} />
|
||||
<span className="task-review-tab__item-summary">{item.path ? `${item.path}: ` : ""}{item.summary}</span>
|
||||
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
|
||||
{displayItems.map((item) => {
|
||||
const checkboxId = `task-review-item-${item.id}`;
|
||||
|
||||
return (
|
||||
<li key={item.id} className="task-review-tab__item card">
|
||||
<div className="task-review-tab__item-inner">
|
||||
<label htmlFor={checkboxId} className="task-review-tab__direct-item task-review-tab__direct-item--selectable">
|
||||
<div className="task-review-tab__summary-wrap">
|
||||
<input id={checkboxId} type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} />
|
||||
<span className="task-review-tab__item-summary">{item.path ? `${item.path}: ` : ""}{item.summary}</span>
|
||||
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
|
||||
</div>
|
||||
<div className="task-review-tab__meta">{formatTimestamp(item.createdAt)}</div>
|
||||
{item.addressing ? (
|
||||
<div className="task-review-tab__meta">Selected: {formatTimestamp(item.addressing.selectedAt)}{item.addressing.startedAt ? ` · Started: ${formatTimestamp(item.addressing.startedAt)}` : ""}{item.addressing.completedAt ? ` · Completed: ${formatTimestamp(item.addressing.completedAt)}` : ""}{item.addressing.error ? ` · Error: ${item.addressing.error}` : ""}</div>
|
||||
) : null}
|
||||
</label>
|
||||
{renderMarkdown ? (
|
||||
<div className="task-review-tab__body markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{item.body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="task-review-tab__body">{item.body}</pre>
|
||||
)}
|
||||
</div>
|
||||
<div className="task-review-tab__meta">{formatTimestamp(item.createdAt)}</div>
|
||||
{item.addressing ? (
|
||||
<div className="task-review-tab__meta">Selected: {formatTimestamp(item.addressing.selectedAt)}{item.addressing.startedAt ? ` · Started: ${formatTimestamp(item.addressing.startedAt)}` : ""}{item.addressing.completedAt ? ` · Completed: ${formatTimestamp(item.addressing.completedAt)}` : ""}{item.addressing.error ? ` · Error: ${item.addressing.error}` : ""}</div>
|
||||
) : null}
|
||||
<pre className="task-review-tab__body">{item.body}</pre>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
{isPrMode && !loading && !error && displayItems.length === 0 ? <div className="task-review-tab__empty">No review items yet.</div> : null}
|
||||
|
||||
@@ -514,6 +514,7 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
|
||||
.workflow-configured-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -282,6 +282,7 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByText(/restores the legacy behavior/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearPluginUiSlotsCache();
|
||||
|
||||
@@ -3,6 +3,8 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TaskReviewTab } from "../TaskReviewTab";
|
||||
import { makeTask } from "./TaskDetailModal.test-helpers";
|
||||
|
||||
const REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY = "fn-task-review-markdown";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
fetchTaskReview: vi.fn(),
|
||||
refreshTaskReview: vi.fn(),
|
||||
@@ -18,6 +20,7 @@ vi.mock("../../api", () => ({
|
||||
describe("TaskReviewTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders direct-mode empty state when no reviewer feedback exists", async () => {
|
||||
@@ -142,6 +145,75 @@ describe("TaskReviewTab", () => {
|
||||
expect(screen.getByText("failed").className).toContain("task-review-tab__status--failed");
|
||||
});
|
||||
|
||||
it("FN-4137 keeps markdown body clicks from toggling the review-item checkbox", async () => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
source: "reviewer-agent",
|
||||
summary: { verdict: "REVISE", reviewType: "code", summary: "Needs fixes" },
|
||||
items: [
|
||||
{
|
||||
id: "reviewer-markdown-click-1",
|
||||
body: "**bold**\n\n[details](https://example.test/review)",
|
||||
author: { login: "reviewer-agent" },
|
||||
createdAt: new Date().toISOString(),
|
||||
summary: "Markdown body click target",
|
||||
},
|
||||
],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
|
||||
render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
const checkbox = await screen.findByRole("checkbox");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
fireEvent.click(screen.getByText("bold"));
|
||||
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("renders markdown by default and persists plain-text toggle preference", async () => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
source: "reviewer-agent",
|
||||
summary: { verdict: "REVISE", reviewType: "code", summary: "Needs fixes" },
|
||||
items: [
|
||||
{
|
||||
id: "reviewer-markdown-1",
|
||||
body: "**bold**\n\n- item one",
|
||||
author: { login: "reviewer-agent" },
|
||||
createdAt: new Date().toISOString(),
|
||||
summary: "Markdown body",
|
||||
},
|
||||
],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
|
||||
const { container, unmount } = render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
await screen.findByText("Markdown body");
|
||||
expect(container.querySelector("strong")?.textContent).toBe("bold");
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-review-markdown-toggle"));
|
||||
|
||||
await waitFor(() => expect(container.querySelector("pre.task-review-tab__body")?.textContent).toContain("**bold**"));
|
||||
expect(window.localStorage.getItem(REVIEW_MARKDOWN_TOGGLE_STORAGE_KEY)).toBe("false");
|
||||
expect(container.querySelector("strong")).toBeNull();
|
||||
|
||||
unmount();
|
||||
const rerendered = render(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
await screen.findByText("Markdown body");
|
||||
await waitFor(() => expect(rerendered.container.querySelector("pre.task-review-tab__body")?.textContent).toContain("**bold**"));
|
||||
expect(screen.getByTestId("task-review-markdown-toggle")).toHaveTextContent("Plain");
|
||||
});
|
||||
|
||||
it("renders review items and queues revision for selected entries", async () => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
|
||||
@@ -884,6 +884,7 @@ describe("WorkflowResultsTab", () => {
|
||||
expect(allCss).toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.workflow-result-mode-toggle\s*\{[^}]*margin-left:\s*0;/);
|
||||
});
|
||||
|
||||
|
||||
it("allows workflow modal controls to wrap on mobile so the close button stays visible", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
|
||||
@@ -2111,8 +2111,8 @@ describe("TaskExecutor global pause behavior", () => {
|
||||
await executor.execute(todoTask as any);
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
paused: undefined,
|
||||
pausedByAgentId: undefined,
|
||||
paused: false,
|
||||
pausedByAgentId: null,
|
||||
status: null,
|
||||
});
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
||||
@@ -2187,8 +2187,8 @@ describe("TaskExecutor global pause behavior", () => {
|
||||
|
||||
// FN-4145: explicit agent completion always clears task-level pause state.
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
paused: undefined,
|
||||
pausedByAgentId: undefined,
|
||||
paused: false,
|
||||
pausedByAgentId: null,
|
||||
status: null,
|
||||
});
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
||||
@@ -2257,8 +2257,8 @@ describe("TaskExecutor global pause behavior", () => {
|
||||
await executor.execute(inProgressTask as any);
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
paused: undefined,
|
||||
pausedByAgentId: undefined,
|
||||
paused: false,
|
||||
pausedByAgentId: null,
|
||||
status: null,
|
||||
});
|
||||
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
|
||||
@@ -2279,6 +2279,143 @@ describe("TaskExecutor global pause behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fn_task_done with paused state (FN-3964 / FN-4167 regression)", () => {
|
||||
it("advances todo + paused tasks through normal completion handoff", async () => {
|
||||
const store = createMockStore();
|
||||
let capturedCustomTools: any[] = [];
|
||||
const todoTask = {
|
||||
id: "FN-001",
|
||||
title: "Paused todo task",
|
||||
description: "T",
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
column: "todo",
|
||||
paused: true,
|
||||
pausedByAgentId: "agent-123",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Step 1", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
store.getTask.mockResolvedValue(todoTask);
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
autoMerge: false,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
});
|
||||
store.moveTask.mockImplementation(async (_id: string, to: string) => ({ ...todoTask, column: to, paused: false }));
|
||||
|
||||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||
capturedCustomTools = opts.customTools || [];
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
|
||||
if (taskDoneTool) {
|
||||
await taskDoneTool.execute("call-1", { summary: "done" });
|
||||
}
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
}) as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog");
|
||||
|
||||
await executor.execute(todoTask as any);
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
paused: false,
|
||||
pausedByAgentId: null,
|
||||
status: null,
|
||||
});
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
|
||||
expect(
|
||||
store.logEntry.mock.calls.some(
|
||||
([id, action]: [string, string]) =>
|
||||
id === "FN-001" && action.includes("Completion handoff deferred — global pause active"),
|
||||
),
|
||||
).toBe(false);
|
||||
// globalPause:true deferred behavior is intentionally covered by the test above.
|
||||
});
|
||||
|
||||
it("completes in-progress + paused tasks after clearing task-level pause state", async () => {
|
||||
const store = createMockStore();
|
||||
let capturedCustomTools: any[] = [];
|
||||
const inProgressTask = {
|
||||
id: "FN-001",
|
||||
title: "Paused in-progress task",
|
||||
description: "T",
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
column: "in-progress",
|
||||
paused: true,
|
||||
pausedByAgentId: "agent-123",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Step 1", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
store.getTask.mockResolvedValue(inProgressTask);
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15000,
|
||||
autoMerge: false,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
});
|
||||
|
||||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||
capturedCustomTools = opts.customTools || [];
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
const taskDoneTool = capturedCustomTools.find((tool: any) => tool.name === "fn_task_done");
|
||||
if (taskDoneTool) {
|
||||
await taskDoneTool.execute("call-1", { summary: "done" });
|
||||
}
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
}) as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog");
|
||||
|
||||
await executor.execute(inProgressTask as any);
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
paused: false,
|
||||
pausedByAgentId: null,
|
||||
status: null,
|
||||
});
|
||||
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-progress");
|
||||
expect(
|
||||
store.logEntry.mock.calls.some(
|
||||
([id, action]: [string, string]) =>
|
||||
id === "FN-001" && action.includes("Completion handoff deferred — global pause active"),
|
||||
),
|
||||
).toBe(false);
|
||||
// globalPause:true deferred behavior is intentionally covered by
|
||||
// "parks todo tasks in in-progress when fn_task_done is called during global pause".
|
||||
});
|
||||
});
|
||||
|
||||
it("takes no action when globalPause remains false", async () => {
|
||||
const store = createMockStore();
|
||||
const disposeFn = vi.fn();
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("TaskExecutor enginePaused soft pause (no agent termination)", () => {
|
||||
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({ paused: undefined, pausedByAgentId: undefined, status: null }),
|
||||
expect.objectContaining({ paused: false, pausedByAgentId: null, status: null }),
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
});
|
||||
|
||||
@@ -4599,8 +4599,8 @@ export class TaskExecutor {
|
||||
// in-flight work. Always clear it on explicit agent completion so the
|
||||
// board cannot strand a completed task in a paused state.
|
||||
await store.updateTask(taskId, {
|
||||
paused: undefined,
|
||||
pausedByAgentId: undefined,
|
||||
paused: false,
|
||||
pausedByAgentId: null,
|
||||
status: null,
|
||||
});
|
||||
await store.logEntry(taskId, "Task marked done by agent");
|
||||
|
||||
Reference in New Issue
Block a user