diff --git a/.changeset/fn-030-refinement-planning-route.md b/.changeset/fn-030-refinement-planning-route.md new file mode 100644 index 0000000000..8f0ad32537 --- /dev/null +++ b/.changeset/fn-030-refinement-planning-route.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Route refinement follow-ups directly into workflow planning lanes. +category: fix +dev: Manual intake workflows use their trait-derived hold lane; automatic workflows retain intake routing. diff --git a/docs/architecture.md b/docs/architecture.md index 1974c11684..2528d79eff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1017,15 +1017,15 @@ Key server capabilities: - Core board components: `Board.tsx`, `Column.tsx`, `TaskCard.tsx`, `TaskDetailModal.tsx`, `ListView.tsx` - **Board column ordering (board view only)**: `todo` cards mirror scheduler pickup order (priority descending, then `createdAt` ascending/FIFO within each priority tier, then task ID ascending). `triage`, `in-progress`, and `archived` use priority descending then task ID ascending, with missing/invalid priority normalized to `normal`. `done` is completion-recency ordered (`columnMovedAt`, then `updatedAt`, then `createdAt`, newest first). In `in-review`, merge-active tasks (`status === "merging"`, `"merging-pr"`, or `"merging-fix"`) are pinned above non-merging tasks, with priority-then-ID ordering within each group. -#### Refinement task routing +#### Refinement task routing (FN-030) -- `fn_task_refine` creates child tasks in `column: "triage"` with `sourceType: "task_refine"` and a dependency on the source task. Refinements are never routed directly to `todo`. -- Refinements still require normal triage specification (PROMPT.md with valid `File Scope`) before execution routing. +- `fn_task_refine` creates a child with `sourceType: "task_refine"`, a dependency on the completed or in-review source task, and a destination resolved from the selected workflow's lifecycle traits. +- Automatic workflows retain their `intake` destination. A manual intake (`autoTriage: false`) is an operator capture lane, so refinements bypass it and enter that workflow's `hold`/Planning lane instead (for example, Coding (Ideas) moves from `ideas` to `todo`). If no usable workflow destination can be resolved, the existing `triage` fallback is preserved for compatibility. Once placed, every refinement follows the normal planning/specification and approval gates for its workflow. - To prevent starvation under large same-priority planning backlogs (FN-4647 pattern), triage polling now prefers `task_refine` rows over non-refinement rows as an ordering tiebreaker within the same priority band. -- **Starved refinement self-healing sweep (Lane B):** `SelfHealingManager.recoverStarvedRefinementTriageTasks()` runs in startup + maintenance sweeps and targets `sourceType: "task_refine"` tasks still in `triage` (`status` `null|planning`) that are unpaused, not actively planning, older than `STARVED_REFINEMENT_RECOVERY_GRACE_MS` (10m), and have observed peer board progress (`STARVED_PEER_PROGRESS_THRESHOLD=3` non-refinement tasks advanced to `todo` after the refinement was created). Remediation is a bounded one-step priority nudge (no direct move-to-`todo`) with cooldown idempotency (`STARVED_REFINEMENT_ESCALATION_COOLDOWN_MS = grace*4`) and run-audit emission `task:auto-recover-starved-refinement` including `{ taskId, ageMs, peerProgressCount, escalation }` metadata. +- **Starved refinement self-healing sweep (Lane B):** `SelfHealingManager.recoverStarvedRefinementTriageTasks()` runs in startup + maintenance sweeps and targets legacy-fallback `sourceType: "task_refine"` tasks still in `triage` (`status` `null|planning`) that are unpaused, not actively planning, older than `STARVED_REFINEMENT_RECOVERY_GRACE_MS` (10m), and have observed peer board progress (`STARVED_PEER_PROGRESS_THRESHOLD=3` non-refinement tasks advanced to `todo` after the refinement was created). Remediation is a bounded one-step priority nudge (no direct move-to-`todo`) with cooldown idempotency (`STARVED_REFINEMENT_ESCALATION_COOLDOWN_MS = grace*4`) and run-audit emission `task:auto-recover-starved-refinement` including `{ taskId, ageMs, peerProgressCount, escalation }` metadata. - **Stale planning liveness invariant:** stale-processing eviction never removes a task with a live, non-aborted triage session (`activeSessions.has(id) && !stuckAborted.has(id)`). It therefore remains in `getProcessingTaskIds()` and cannot be finalized, cleared for replanning, or priority-nudged by planning recovery sweeps. Hung promises without a session and stuck-aborted/disposed sessions remain reclaimable after the stale threshold. -- Approval semantics are unchanged: with `requirePlanApproval=true`, refinements stop at `status: "awaiting-approval"`; otherwise they move to `todo` after spec finalization. -- Regression coverage lives in `packages/engine/src/__tests__/triage-refinement-routing.test.ts` and locks four guarantees: bounded promotion under backlog pressure, approval-gate preservation, PROMPT-before-`todo` invariant, and unchanged baseline ordering for non-refinement-only triage sets. +- Approval semantics are unchanged: with `requirePlanApproval=true`, refinements stop at `status: "awaiting-approval"`; otherwise they continue through the selected workflow's planning lane after spec finalization. +- Regression coverage lives in `packages/engine/src/__tests__/triage-refinement-routing.test.ts` and the PostgreSQL refinement/comment suites, which cover automatic intake, manual-intake bypass, workflow selection, seed continuity, and comment-created children. - Task detail surface is shared through `TaskDetailContent` (exported from `TaskDetailModal.tsx`): desktop/tablet `ListView` renders it inline in the split right pane, while mobile and non-list entry points continue using `TaskDetailModal`. - In desktop split mode, `ListView` now uses a compact sidebar-first control layout (count/actions/summary chips + collapsible "View options" panel) to keep list controls dense alongside the inline detail pane; mobile keeps the card-first flow with a toolbar "View options" entry point for the same visibility/filter toggles. - Chat system UI: `ChatView.tsx`, `QuickChatFAB.tsx` @@ -2026,14 +2026,14 @@ Git dashboard routes are registered in `register-git-github.ts`. ### Stranded refinement affordance (Lane C) -Fusion adds an operator-first API surface to diagnose and expedite refinement tasks that remain in Planning (`triage`) without bypassing plan/approval gates from FN-4657. +Fusion adds an operator-first API surface to diagnose and expedite refinement tasks that remain in a workflow planning lane (including the legacy `triage` fallback) without bypassing plan/approval gates from FN-4657. | Method | Path | Description | |---|---|---| | GET | `/api/tasks/recommendations` | List completed-task recommendations scoped to complete-role columns. Row-paginated with `limit`/`offset` (maximum 200) and returns `hasMore` plus `totalRowCount`. | -| GET | `/api/tasks/stranded-refinements` | List stranded refinement diagnostics (`sourceType=task_refine`, `column=triage`, `paused!=true`) with reasons and recommendation. Supports `?freshnessMinutes=` (1-1440). | +| GET | `/api/tasks/stranded-refinements` | List stranded legacy-fallback refinement diagnostics (`sourceType=task_refine`, `column=triage`, `paused!=true`) with reasons and recommendation. Supports `?freshnessMinutes=` (1-1440). | | GET | `/api/tasks/:id/stranded-refinement` | Return one refinement diagnostic row plus PROMPT.md presence and dependency-resolution status. | -| POST | `/api/tasks/:id/expedite-refinement` | Request bounded expedite for a triage refinement. Clears `nextRecoveryAt` for stale/backoff rows; returns `requiresOperatorAction` for `awaiting-approval`/`failed`/`stuck-killed` without mutating status. | +| POST | `/api/tasks/:id/expedite-refinement` | Request bounded expedite for a legacy-fallback triage refinement. Clears `nextRecoveryAt` for stale/backoff rows; returns `requiresOperatorAction` for `awaiting-approval`/`failed`/`stuck-killed` without mutating status. | Stranded reasons are: `untriaged-stale`, `awaiting-approval`, `failed`, `stuck-killed`, and `recovery-backoff`. @@ -2043,7 +2043,7 @@ Non-bypass guarantees: - Expedite never clears `awaiting-approval` (or failed/stuck statuses). - `POST /api/tasks/:id/approve-plan` remains the only route that clears `awaiting-approval` and promotes approved plans. -This complements FN-4657's durable triage routing fix; it does not replace triage specification or plan-approval policy. +This complements FN-030's durable workflow-aware routing; it does not replace planning specification or plan-approval policy. | Method | Path | Description | |---|---|---| diff --git a/docs/task-management.md b/docs/task-management.md index e44f77746d..10a1359923 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -630,7 +630,7 @@ Execution ownership is preserved for active work: This is distinct from steering comments: steering feedback targets the currently running executor session, while comment-driven re-triage requests a fresh specification pass for planned work. ## Refinement Tasks -`fn task refine ` creates a new planning task that depends on the original done/in-review task. +`fn task refine ` creates a new planning follow-up that depends on the original done/in-review task. Example: @@ -640,10 +640,11 @@ fn task refine FN-042 --feedback "Add explicit rollback tests for partial failur Behavior: -- New title format: `Refinement: ` -- New task depends on source task -- Created in `planning` -- Refinement tasks inherit the source task's GitHub tracking state (unlinked sources opt out; linked sources inherit `enabled` and optional `repoOverride`, but never copy the source issue link) +- The new title is derived from the first meaningful line of the operator's feedback; the full feedback remains in the description. +- The new task depends on the source task and keeps `sourceType: "task_refine"` lineage. +- Placement is workflow-aware: automatic workflows retain their trait-derived `intake`; manual intakes (`autoTriage: false`) bypass the capture lane and use the workflow's trait-derived `hold`/Planning lane. If no workflow destination is available, the existing `triage` fallback is retained. +- The selected workflow and refinement seed prompt are persisted with the child so normal planning and approval processing can continue from the returned column. +- Refinement tasks inherit the source task's GitHub tracking state (unlinked sources opt out; linked sources inherit `enabled` and optional `repoOverride`, but never copy the source issue link). ## Archive and Restore diff --git a/packages/cli/src/commands/__tests__/task.test.ts b/packages/cli/src/commands/__tests__/task.test.ts index 95bfc664da..ff18cd754f 100644 --- a/packages/cli/src/commands/__tests__/task.test.ts +++ b/packages/cli/src/commands/__tests__/task.test.ts @@ -2681,7 +2681,7 @@ describe("runTaskRefine", () => { mockRefineTask = vi.fn().mockResolvedValue({ id: "FN-002", description: "Refinement of FN-001", - column: "triage", + column: "todo", dependencies: ["FN-001"], steps: [], currentStep: 0, @@ -2717,6 +2717,13 @@ describe("runTaskRefine", () => { expect(successLine![0]).toContain("FN-002"); expect(successLine![0]).toContain("FN-001"); + const columnLine = logSpy.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].includes("Column:"), + ); + expect(columnLine).toBeDefined(); + expect(columnLine![0]).toContain("Column: todo"); + expect(columnLine![0]).not.toContain("triage"); + // Check that dependency is printed const depLine = logSpy.mock.calls.find( (call) => typeof call[0] === "string" && call[0].includes("Dependency:"), diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index f8a48dd2ef..8b1c17b27e 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1525,7 +1525,7 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam console.log(); console.log(` ✓ Created refinement ${newTask.id} for ${id}`); - console.log(` Column: triage`); + console.log(` Column: ${newTask.column}`); console.log(` Dependency: ${id}`); console.log(` Path: .fusion/tasks/${newTask.id}/`); console.log(); diff --git a/packages/core/src/__tests__/postgres/refine-duplicate-task.pg.test.ts b/packages/core/src/__tests__/postgres/refine-duplicate-task.pg.test.ts index b6f641850f..8cea7bc813 100644 --- a/packages/core/src/__tests__/postgres/refine-duplicate-task.pg.test.ts +++ b/packages/core/src/__tests__/postgres/refine-duplicate-task.pg.test.ts @@ -8,6 +8,9 @@ * async layer, so both surfaces must persist against PostgreSQL. */ import { describe, it, expect } from "vitest"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buildRefinementSeedPrompt } from "../../mesh/mesh-task-replication.js"; import { pgDescribe, createTaskStoreForTest, @@ -63,6 +66,133 @@ pgDescribe("refineTask / duplicateTask backend mode (PostgreSQL)", () => { * FN-8188 requires refinements to use the same project-default optional-group * seed and persisted selection as createTask, including empty and absent defaults. */ + it("refineTask keeps automatic default Coding in its planning lane", async () => { + const h = await makeHarness(); + try { + await h.store.setDefaultWorkflowId("builtin:coding"); + const source = await h.store.createTask({ + title: "Automatic workflow source", + description: "Completed automatic workflow work", + column: "done", + }); + + const refined = await h.store.refineTask(source.id, "Keep automatic planning actionable"); + + expect(refined.column).toBe("todo"); + expect(refined.column).not.toBe("triage"); + expect((await h.store.getTask(refined.id)).column).toBe("todo"); + } finally { + await teardown(); + } + }); + + it("routes Coding (Ideas) refinements to Planning and preserves selection and seed", async () => { + const h = await makeHarness(); + try { + const source = await h.store.createTask({ + title: "Ideas source", + description: "Completed work selected in Coding (Ideas)", + workflowId: "builtin:coding-ideas", + column: "done", + } as never); + + const refined = await h.store.refineTask(source.id, "Make the empty state actionable"); + const fetched = await h.store.getTask(refined.id); + const prompt = await readFile(join(h.store.taskDir(refined.id), "PROMPT.md"), "utf8"); + + expect(refined.column).toBe("todo"); + expect(refined.column).not.toBe("ideas"); + expect(fetched.column).toBe("todo"); + expect(fetched.sourceParentTaskId).toBe(source.id); + expect(fetched.dependencies).toEqual([source.id]); + expect(await h.store.getTaskWorkflowSelectionAsync(refined.id)).toMatchObject({ + workflowId: "builtin:coding-ideas", + }); + expect(prompt).toBe(buildRefinementSeedPrompt(refined.title ?? refined.id, refined.description)); + } finally { + await teardown(); + } + }); + + it("routes multiple refinements in a renamed manual workflow to its hold lane", async () => { + const h = await makeHarness(); + try { + const definition = await h.store.createWorkflowDefinition({ + name: "Renamed manual refinement workflow", + kind: "workflow", + ir: { + version: "v2", + name: "Renamed manual refinement workflow", + columns: [ + { id: "capture", name: "Capture", traits: [{ trait: "intake", config: { autoTriage: false } }] }, + { id: "ready", name: "Ready to plan", traits: [{ trait: "hold", config: { release: "capacity" } }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + { id: "filed", name: "Filed", traits: [{ trait: "archived" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "capture" }, + { id: "end", kind: "end", column: "shipped" }, + ], + edges: [{ from: "start", to: "end" }], + }, + } as never); + const source = await h.store.createTask({ + title: "Renamed workflow source", + description: "Completed work in the renamed workflow", + workflowId: definition.id, + column: "shipped", + } as never); + + const first = await h.store.refineTask(source.id, "Add the first follow-up"); + const second = await h.store.refineTask(source.id, "Add the second follow-up"); + + for (const child of [first, second]) { + const fetched = await h.store.getTask(child.id); + expect(fetched.column).toBe("ready"); + expect(fetched.column).not.toBe("capture"); + expect(fetched.sourceParentTaskId).toBe(source.id); + expect(fetched.dependencies).toEqual([source.id]); + expect(await h.store.getTaskWorkflowSelectionAsync(fetched.id)).toMatchObject({ workflowId: definition.id }); + } + } finally { + await teardown(); + } + }); + + it("keeps the legacy fallback when a manual workflow has no Planning hold", async () => { + const h = await makeHarness(); + try { + const definition = await h.store.createWorkflowDefinition({ + name: "Manual workflow without hold", + kind: "workflow", + ir: { + version: "v2", + name: "Manual workflow without hold", + columns: [ + { id: "capture", name: "Capture", traits: [{ trait: "intake", config: { autoTriage: false } }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "capture" }, + { id: "end", kind: "end", column: "shipped" }, + ], + edges: [{ from: "start", to: "end" }], + }, + } as never); + const source = await h.store.createTask({ + description: "Completed source without a Planning hold", + workflowId: definition.id, + column: "shipped", + } as never); + + const refined = await h.store.refineTask(source.id, "Keep the fallback behavior"); + + expect(refined.column).toBe("triage"); + } finally { + await teardown(); + } + }); + it("refineTask inherits default-on workflow groups and selection like createTask", async () => { const h = await makeHarness(); try { diff --git a/packages/core/src/__tests__/postgres/store-comments.pg.test.ts b/packages/core/src/__tests__/postgres/store-comments.pg.test.ts index 61d0ca405f..f4065d9e6f 100644 --- a/packages/core/src/__tests__/postgres/store-comments.pg.test.ts +++ b/packages/core/src/__tests__/postgres/store-comments.pg.test.ts @@ -13,6 +13,9 @@ */ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buildRefinementSeedPrompt } from "../../mesh/mesh-task-replication.js"; import { pgDescribe, createSharedPgTaskStoreTestHarness, @@ -151,6 +154,35 @@ pgTest("TaskStore addComment steering + refinement (PostgreSQL)", () => { expect(after.some((t: { description?: string }) => (t.description ?? "").includes("empty case"))).toBe(true); }); + it("routes a user comment refinement from Coding (Ideas) to Planning", async () => { + const store = h.store(); + const source = await store.createTask({ + title: "Ideas comment source", + description: "Completed Coding (Ideas) work", + workflowId: "builtin:coding-ideas", + column: "done", + } as never); + const before = await store.listTasks({ slim: true } as never); + + await store.addComment(source.id, "Please make the empty state actionable", "user"); + + const after = await store.listTasks({ slim: true } as never); + const children = after.filter((task: { sourceParentTaskId?: string }) => task.sourceParentTaskId === source.id); + expect(after.length).toBe(before.length + 1); + expect(children).toHaveLength(1); + const child = await store.getTask(children[0].id); + const prompt = await readFile(join(store.taskDir(child.id), "PROMPT.md"), "utf8"); + expect(child.column).toBe("todo"); + expect(child.column).not.toBe("ideas"); + expect(child.dependencies).toEqual([source.id]); + expect(await store.getTaskWorkflowSelectionAsync(child.id)).toMatchObject({ workflowId: "builtin:coding-ideas" }); + expect(prompt).toBe(buildRefinementSeedPrompt(child.title ?? child.id, child.description)); + + const beforeAgentComment = (await store.listTasks({ slim: true } as never)).length; + await store.addComment(source.id, "Agent status update", "agent"); + expect((await store.listTasks({ slim: true } as never)).length).toBe(beforeAgentComment); + }); + it("adds a steering comment and persists it", async () => { const store = h.store(); const task = await store.createTask({ description: "steering target" }); diff --git a/packages/core/src/task-store/update-task-deps.ts b/packages/core/src/task-store/update-task-deps.ts index e883818ffc..6629c05a29 100644 --- a/packages/core/src/task-store/update-task-deps.ts +++ b/packages/core/src/task-store/update-task-deps.ts @@ -114,14 +114,17 @@ export async function refineTaskImpl(store: TaskStore, id: string, feedback: str } /* - FNXC:MergedPlanningColumn 2026-07-31-22:35 (missed creation surface — refine): - Resolve the inherited workflow's intake lane instead of the legacy `"triage"` literal. The - hardcoded id landed refinements in a column the merged coding workflow no longer declares — - surfaced on the live board as an amber PLANNING badge (badge color keys off the raw column id) - on a card invisible to trait-driven sweeps until the undeclared-column re-home. Literal survives - only as the last resort for a store that cannot resolve any workflow, matching createTask. + FNXC:RefinementPlanningRouting 2026-08-19-05:26: + Refinements should be actionable immediately, so a workflow's manual capture lane is bypassed + when that workflow declares a Planning hold. Automatic workflows retain their intake destination; + a manual workflow without a usable hold, or any unresolved workflow, preserves the existing + `triage` fallback. Resolve these facts with the same pending selection that is persisted below so + placement and workflow selection cannot disagree, and use trait-derived roles rather than column ids. */ - const refineIntakeColumn = (await resolveWorkflowIntakeFacts(store, pendingWorkflowSelection?.workflowId)).intake ?? "triage"; + const refinementIntakeFacts = await resolveWorkflowIntakeFacts(store, pendingWorkflowSelection?.workflowId); + const refineIntakeColumn = refinementIntakeFacts.manual + ? refinementIntakeFacts.hold ?? "triage" + : refinementIntakeFacts.intake ?? "triage"; const newTask = await store.createTaskWithDistributedReservation({ description: feedback.trim() }, { createTaskWithId: async (newId) => { // FN-5077: keep deterministic "Refinement" fallback when normalized refinement label is unusable (null). diff --git a/packages/dashboard/app/__tests__/api-auth.test.ts b/packages/dashboard/app/__tests__/api-auth.test.ts index 2af2cea41a..404c6cff39 100644 --- a/packages/dashboard/app/__tests__/api-auth.test.ts +++ b/packages/dashboard/app/__tests__/api-auth.test.ts @@ -601,7 +601,7 @@ describe("refineTask", () => { const FAKE_REFINED_TASK: Task = { id: "FN-002", description: "Refinement of FN-001", - column: "triage", + column: "todo", dependencies: ["FN-001"], steps: [], currentStep: 0, @@ -616,7 +616,8 @@ describe("refineTask", () => { const result = await refineTask("FN-001", "Need to add more tests and improve error handling"); expect(result.id).toBe("FN-002"); - expect(result.column).toBe("triage"); + expect(result.column).toBe("todo"); + expect(result.column).not.toBe("triage"); expect(result.dependencies).toContain("FN-001"); expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/refine", { headers: API_JSON_HEADERS,