Remove complex-task splitting and parent-deletion behavior across core, dashboard, and engine. - Remove subtask splitting, breakdown UI, and related API routes and lifecycle handling. - Add the migration and prompt/documentation updates that enforce the simplified task model. - Update affected tests and integrations for the removed behavior. Files changed: .changeset/fn-074-removal.md | 7 + docs/dashboard-guide.md | 17 +- docs/gitlab-parity-inventory.md | 7 +- docs/mcp.md | 6 +- docs/settings-reference.md | 16 +- docs/task-management.md | 31 +- docs/workflow-steps.md | 10 +- .../cli/skill/fusion/references/task-structure.md | 2 +- .../cli/skill/fusion/workflows/specifications.md | 4 +- packages/core/src/__tests__/agent-prompts.test.ts | 19 +- .../builtin-workflow-settings-triage.test.ts | 32 +- .../src/__tests__/postgres/schema-applier.test.ts | 2 +- .../core/src/__tests__/prompt-overrides.test.ts | 3 +- ...-deleted-observed-dispatch-side-effects.test.ts | 3 - .../task-deleted-outbox-consumer-backoff.test.ts | 1 - packages/core/src/agents/agent-prompts.ts | 56 +- packages/core/src/index.ts | 2 +- .../0062_remove_task_subtask_splitting.sql | 2 + packages/core/src/postgres/schema-applier.ts | 14 +- packages/core/src/postgres/schema/project.ts | 1 - packages/core/src/store.ts | 12 +- packages/core/src/task-delete-attribution.ts | 20 +- packages/core/src/task-delete-notice.ts | 7 +- .../__tests__/lifecycle-outbox-writer.test.ts | 4 +- .../core/src/task-store/archive-lifecycle-2.ts | 13 +- packages/core/src/task-store/archive-lifecycle.ts | 6 +- packages/core/src/task-store/lifecycle-outbox.ts | 2 - packages/core/src/task-store/persistence.ts | 2 - packages/core/src/task-store/serialization.ts | 2 - packages/core/src/task-store/task-creation.ts | 2 - .../src/task-store/task-deleted-outbox-consumer.ts | 4 - packages/core/src/task-store/task-mutation-ops.ts | 2 +- packages/core/src/task-store/task-row-mappers.ts | 2 +- packages/core/src/tasks/prompt-overrides.ts | 35 - packages/core/src/types.ts | 12 - packages/core/src/types/mesh/archive-planning.ts | 1 - packages/core/src/types/task/task-core.ts | 3 - .../src/workflows/builtin-workflow-settings.ts | 86 -- packages/dashboard/app/App.tsx | 15 - .../mission-planning-modals-mobile.test.ts | 15 - packages/dashboard/app/api/legacy.ts | 18 +- packages/dashboard/app/api/planning/ai-text.ts | 171 +--- packages/dashboard/app/api/planning/planning.ts | 46 - packages/dashboard/app/api/tasks/tasks.ts | 4 +- packages/dashboard/app/components/AppModals.tsx | 39 +- packages/dashboard/app/components/Board.tsx | 10 +- packages/dashboard/app/components/Column.tsx | 9 +- .../dashboard/app/components/InlineCreateCard.tsx | 63 +- packages/dashboard/app/components/Lane.tsx | 3 +- packages/dashboard/app/components/ListView.tsx | 10 +- packages/dashboard/app/components/NewTaskModal.tsx | 8 +- .../dashboard/app/components/PlanningModeModal.css | 128 --- .../dashboard/app/components/PlanningModeModal.tsx | 566 +---------- .../dashboard/app/components/QuickEntryBox.tsx | 43 +- .../dashboard/app/components/SettingsModal.tsx | 2 - .../app/components/SubtaskBreakdownModal.tsx | 932 ----------------- packages/dashboard/app/components/TaskCard.tsx | 1 - packages/dashboard/app/components/TaskForm.tsx | 23 +- .../__tests__/AgentPromptsManager.test.tsx | 1 - .../app/components/__tests__/AppModals.test.tsx | 92 +- .../app/components/__tests__/Board.test.tsx | 69 +- .../components/__tests__/InlineCreateCard.test.tsx | 170 ---- .../app/components/__tests__/ListView.test.tsx | 38 - .../app/components/__tests__/NewTaskModal.test.tsx | 48 - .../components/__tests__/QuickEntryBox.test.tsx | 293 +----- .../__tests__/SubtaskBreakdownModal.test.tsx | 933 ----------------- ...skDetail.mobile-transition.board-panel.test.tsx | 1 - .../__tests__/TaskDetail.swipe-back.test.tsx | 1 - .../app/components/__tests__/TaskForm.test.tsx | 67 -- .../__tests__/WorkflowSettingsPanel.test.tsx | 51 - .../app/components/__tests__/board-mobile.test.tsx | 20 - .../composer-settings-read-isolation.test.tsx | 30 - .../components/__tests__/migratedModalFixtures.tsx | 2 - .../app/components/dashboard/MainContent.tsx | 5 - .../dashboard/__tests__/DashboardBanners.test.tsx | 1 - .../__tests__/MainContent.graph-popout.test.tsx | 1 - .../dashboard/app/components/dashboard/types.ts | 2 - .../app/components/workflow-setting-display.ts | 10 - ...ckgroundSessions.resume-instrumentation.test.ts | 1 - .../hooks/__tests__/useBackgroundSessions.test.ts | 25 +- .../app/hooks/__tests__/useModalManager.test.ts | 3 - packages/dashboard/app/hooks/modalPersistence.ts | 14 - .../dashboard/app/hooks/useBackgroundSessions.ts | 9 +- packages/dashboard/app/hooks/useModalManager.ts | 58 +- packages/dashboard/app/hooks/useProjectActions.ts | 2 +- packages/dashboard/app/hooks/useTaskHandlers.ts | 9 - packages/dashboard/app/utils/builtinPrompts.ts | 7 - packages/dashboard/app/utils/projectStorage.ts | 1 - .../src/__tests__/ai-session-diagnostics.test.ts | 5 +- .../src/__tests__/github-tracking-state.test.ts | 97 -- .../src/__tests__/gitlab-delete-close.test.ts | 6 +- .../gitlab-parity-inventory-documentation.test.ts | 1 - .../src/__tests__/gitlab-split-close.test.ts | 125 --- .../planning-flow-diagnostics-guardrail.test.ts | 1 - .../src/__tests__/process-lifecycle.test.ts | 1 - .../dashboard/src/__tests__/routes-auth.test.ts | 2 - .../src/__tests__/routes-automation.test.ts | 2 - .../dashboard/src/__tests__/routes-git.test.ts | 2 - .../dashboard/src/__tests__/routes-github.test.ts | 92 -- .../src/__tests__/routes-planning-tracking.test.ts | 130 --- .../src/__tests__/routes-planning.test.ts | 1062 +------------------- .../dashboard/src/__tests__/routes-system.test.ts | 2 - .../dashboard/src/__tests__/routes-tasks.test.ts | 902 ----------------- .../shared-branch-group-entry-points.test.ts | 93 -- packages/dashboard/src/ai-session-diagnostics.ts | 3 +- packages/dashboard/src/ai-session-store.ts | 6 +- packages/dashboard/src/ai-session-timeout.ts | 3 +- packages/dashboard/src/github-tracking-state.ts | 43 +- packages/dashboard/src/gitlab-delete-close.ts | 7 +- packages/dashboard/src/gitlab-lifecycle.ts | 4 +- packages/dashboard/src/gitlab-split-close.ts | 103 -- packages/dashboard/src/gitlab-tracking-state.ts | 2 +- packages/dashboard/src/index.ts | 1 - packages/dashboard/src/planning.ts | 171 ---- packages/dashboard/src/routes.ts | 6 - ...er-planning-subtask-routes.parent-close.test.ts | 120 --- .../dashboard/src/routes/register-git-github.ts | 6 - .../src/routes/register-planning-subtask-routes.ts | 773 +------------- .../src/routes/register-task-workflow-routes.ts | 6 +- packages/dashboard/src/server.ts | 8 - packages/dashboard/src/subtask-breakdown.ts | 833 --------------- packages/dashboard/src/triage-trait.ts | 93 +- .../src/__tests__/agent-task-read-tools.test.ts | 1 - .../triage-duplicate-search-regression.test.ts | 3 - .../triage-split-into-subtasks-delete.test.ts | 222 ---- packages/engine/src/__tests__/triage.test.ts | 519 +--------- packages/engine/src/triage.ts | 239 +---- 127 files changed, 214 insertions(+), 10001 deletions(-) Fusion-Task-Id: FN-074 Fusion-Task-Lineage: 23207799-4ec4-4ec7-bcd7-83cba4d98f53 Co-authored-by: Fusion <noreply@runfusion.ai>
170 lines
7.9 KiB
TypeScript
170 lines
7.9 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { MAX_TASK_LIST_TEXT_CHARS, type AgentPermissionPolicy, type Task, type TaskDetail, type TaskStore } from "@fusion/core";
|
|
import { createPlanningBoardTools } from "../../../dashboard/src/planning-board-tools.js";
|
|
import { createTaskReadTools } from "../agent-tools.js";
|
|
import { HeartbeatMonitor } from "../agent-heartbeat.js";
|
|
import { evaluateAgentActionGate } from "../agents/agent-action-gate.js";
|
|
import { COORDINATION_EXEMPT_TOOLS, READONLY_FN_TOOLS } from "../execution/gating-classifications.js";
|
|
import { classifyPermanentAgentToolCall } from "../agents/permanent-agent-gating.js";
|
|
import { TriageProcessor } from "../triage.js";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
const lockedDownPolicy: AgentPermissionPolicy = {
|
|
presetId: "locked-down",
|
|
rules: {
|
|
git_write: "block",
|
|
file_write_delete: "block",
|
|
command_execution: "block",
|
|
network_api: "block",
|
|
task_agent_mutation: "block",
|
|
},
|
|
};
|
|
|
|
type TaskReadResult = Awaited<ReturnType<ReturnType<typeof createTaskReadTools>[number]["execute"]>>;
|
|
|
|
function textOf(result: TaskReadResult): string {
|
|
expect(result.content).toHaveLength(1);
|
|
expect(result.content[0]).toMatchObject({ type: "text" });
|
|
const text = result.content[0]?.text ?? "";
|
|
expect(text.trim().length).toBeGreaterThan(0);
|
|
return text;
|
|
}
|
|
|
|
function task(overrides: Partial<Task> & Pick<Task, "id">): Task {
|
|
return {
|
|
id: overrides.id,
|
|
title: overrides.title,
|
|
description: overrides.description ?? `Description for ${overrides.id}`,
|
|
column: overrides.column ?? "todo",
|
|
dependencies: overrides.dependencies ?? [],
|
|
steps: overrides.steps ?? [],
|
|
currentStep: overrides.currentStep ?? 0,
|
|
...overrides,
|
|
} as Task;
|
|
}
|
|
|
|
function taskDetail(overrides: Partial<TaskDetail> & Pick<TaskDetail, "id">): TaskDetail {
|
|
return {
|
|
...task(overrides),
|
|
prompt: overrides.prompt ?? "# Prompt body",
|
|
} as TaskDetail;
|
|
}
|
|
|
|
function createStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|
return {
|
|
listTasks: vi.fn(async () => []),
|
|
searchTasks: vi.fn(async () => []),
|
|
getTask: vi.fn(async (id: string) => taskDetail({ id })),
|
|
getSettings: vi.fn(async () => ({})),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
...overrides,
|
|
} as unknown as TaskStore;
|
|
}
|
|
|
|
function toolNames(tools: Array<{ name: string }>): string[] {
|
|
return tools.map((tool) => tool.name);
|
|
}
|
|
|
|
function extractRegisteredCliTaskReadNames(): string[] {
|
|
const source = readFileSync(resolve(__dirname, "../../../cli/src/extension.ts"), "utf8");
|
|
return [...source.matchAll(/name:\s*"(fn_task_(?:list|show|search|get))"/g)].map((match) => match[1]!);
|
|
}
|
|
|
|
describe("shared task read tools", () => {
|
|
it("returns the canonical heartbeat task-read names in order", () => {
|
|
expect(toolNames(createTaskReadTools(createStore()))).toEqual([
|
|
"fn_task_list",
|
|
"fn_task_show",
|
|
"fn_task_search",
|
|
]);
|
|
});
|
|
|
|
it("returns non-empty bounded text for empty, populated, no-match, and oversized boards", async () => {
|
|
const emptyTools = createTaskReadTools(createStore());
|
|
expect(textOf(await emptyTools[0]!.execute("list-empty", {}))).toBe("No active tasks.");
|
|
expect(textOf(await emptyTools[2]!.execute("search-empty", { query: "missing" }))).toBe("No tasks matched.");
|
|
|
|
const populatedStore = createStore({
|
|
listTasks: vi.fn(async () => [
|
|
task({ id: "FN-001", title: "Active task", column: "todo", dependencies: ["FN-000"] }),
|
|
task({ id: "FN-002", title: "Done task", column: "done" }),
|
|
]),
|
|
searchTasks: vi.fn(async () => [task({ id: "FN-003", title: "Search hit", column: "done" })]),
|
|
getTask: vi.fn(async () => taskDetail({ id: "FN-004", title: "Show me", prompt: "# Prompt" })),
|
|
});
|
|
const populatedTools = createTaskReadTools(populatedStore);
|
|
const listText = textOf(await populatedTools[0]!.execute("list-populated", {}));
|
|
expect(listText).toContain("FN-001 (todo): Active task [deps: FN-000]");
|
|
expect(listText).not.toContain("FN-002");
|
|
expect(textOf(await populatedTools[1]!.execute("show-populated", { id: "FN-004" }))).toContain("PROMPT.md:");
|
|
expect(textOf(await populatedTools[2]!.execute("search-populated", { query: "search", includeDone: true }))).toContain("FN-003 (done): Search hit");
|
|
|
|
const oversizedTasks = Array.from({ length: 120 }, (_, index) => task({
|
|
id: `FN-${String(index + 100).padStart(3, "0")}`,
|
|
title: `Oversized ${index} ${"x".repeat(120)}`,
|
|
column: "todo",
|
|
}));
|
|
const oversizedStore = createStore({
|
|
listTasks: vi.fn(async () => oversizedTasks),
|
|
searchTasks: vi.fn(async () => oversizedTasks),
|
|
});
|
|
const oversizedTools = createTaskReadTools(oversizedStore);
|
|
const oversizedListText = textOf(await oversizedTools[0]!.execute("list-oversized", {}));
|
|
const oversizedSearchText = textOf(await oversizedTools[2]!.execute("search-oversized", { query: "oversized" }));
|
|
expect(oversizedListText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
|
|
expect(oversizedSearchText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
|
|
expect(oversizedListText).toContain("truncated to fit");
|
|
});
|
|
|
|
it("exposes task reads through the shared heartbeat helper and task-scoped heartbeat tools", () => {
|
|
const monitor = new HeartbeatMonitor({ store: {} as never, taskStore: createStore(), rootDir: "/tmp/fn-test" });
|
|
const sharedNames = toolNames((monitor as unknown as { createSharedHeartbeatWorkTools: (store: TaskStore) => Array<{ name: string }> }).createSharedHeartbeatWorkTools(createStore()));
|
|
expect(sharedNames.slice(0, 3)).toEqual(["fn_task_list", "fn_task_show", "fn_task_search"]);
|
|
|
|
const taskScopedNames = toolNames(monitor.createHeartbeatTools("agent-1", createStore(), "FN-001"));
|
|
expect(taskScopedNames).toEqual(expect.arrayContaining(["fn_task_list", "fn_task_show", "fn_task_search"]));
|
|
});
|
|
|
|
it("positively classifies all task-read names as read-only", () => {
|
|
for (const toolName of ["fn_task_search", "fn_task_get", "fn_task_list", "fn_task_show"] as const) {
|
|
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
|
|
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes(toolName)).toBe(true);
|
|
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
|
|
const decision = evaluateAgentActionGate({ agentId: "agent-1", toolName, args: {}, permissionPolicy: lockedDownPolicy });
|
|
expect(decision).toMatchObject({ disposition: "allow", category: "exempt", operation: toolName });
|
|
}
|
|
});
|
|
|
|
it("pins per-surface task-read tool name parity on canonical fn_task_show", () => {
|
|
const triageProcessor = new TriageProcessor(createStore() as never, "/tmp/fn-test");
|
|
const triageNames = toolNames((triageProcessor as unknown as { createTriageTools: (opts: unknown) => Array<{ name: string }> }).createTriageTools({
|
|
parentTaskId: "FN-TRIAGE",
|
|
allowTaskCreate: true,
|
|
})).filter((name) => name.startsWith("fn_task_") && name !== "fn_task_create");
|
|
|
|
const surfaces = {
|
|
triage: triageNames,
|
|
planningBoard: toolNames(createPlanningBoardTools(createStore())).filter((name) => name.startsWith("fn_task_")),
|
|
cliExtension: extractRegisteredCliTaskReadNames(),
|
|
sharedFactory: toolNames(createTaskReadTools(createStore())),
|
|
};
|
|
|
|
expect(surfaces).toEqual({
|
|
triage: ["fn_task_list", "fn_task_search", "fn_task_show"],
|
|
planningBoard: ["fn_task_list", "fn_task_show"],
|
|
cliExtension: ["fn_task_list", "fn_task_show"],
|
|
sharedFactory: ["fn_task_list", "fn_task_show", "fn_task_search"],
|
|
});
|
|
const deprecatedGetName = ["fn_task", "get"].join("_");
|
|
for (const names of Object.values(surfaces)) {
|
|
expect(names).toContain("fn_task_show");
|
|
expect(names).not.toContain(deprecatedGetName);
|
|
}
|
|
});
|
|
});
|