FN-030: route refinement tasks directly to planning
Route refinement and duplicate-task handling through the planning path while preserving task metadata and API behavior.\n\n- Route CLI refinement commands directly into planning.\n- Harden dependency updates and duplicate-task refinement persistence.\n- Expand PostgreSQL, CLI, and dashboard authorization coverage.\n- Document the refined task lifecycle and add the published-package changeset.\n\nFiles changed:\n .changeset/fn-030-refinement-planning-route.md | 7 ++\n docs/architecture.md | 20 ++--\n docs/task-management.md | 11 +-\n packages/cli/src/commands/__tests__/task.test.ts | 9 +-\n packages/cli/src/commands/task.ts | 2 +-\n .../postgres/refine-duplicate-task.pg.test.ts | 130 +++++++++++++++++++++\n .../__tests__/postgres/store-comments.pg.test.ts | 32 +++++\n packages/core/src/task-store/update-task-deps.ts | 17 +--\n packages/dashboard/app/__tests__/api-auth.test.ts | 5 +-\n 9 files changed, 207 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-030 Fusion-Task-Lineage: 885c0a9f-d8bf-4abf-9ec9-dca5a01648e8 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-030-refinement-planning-route.md
Normal file
7
.changeset/fn-030-refinement-planning-route.md
Normal file
@@ -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.
|
||||||
@@ -1017,15 +1017,15 @@ Key server capabilities:
|
|||||||
- Core board components: `Board.tsx`, `Column.tsx`, `TaskCard.tsx`, `TaskDetailModal.tsx`, `ListView.tsx`
|
- 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.
|
- **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`.
|
- `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.
|
||||||
- Refinements still require normal triage specification (PROMPT.md with valid `File Scope`) before execution routing.
|
- 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.
|
- 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.
|
- **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.
|
- 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 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.
|
- 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`.
|
- 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.
|
- 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`
|
- 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)
|
### 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 |
|
| 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/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. |
|
| 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`.
|
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).
|
- 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.
|
- `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 |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Refinement Tasks
|
||||||
|
|
||||||
`fn task refine <id>` creates a new planning task that depends on the original done/in-review task.
|
`fn task refine <id>` creates a new planning follow-up that depends on the original done/in-review task.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -640,10 +640,11 @@ fn task refine FN-042 --feedback "Add explicit rollback tests for partial failur
|
|||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- New title format: `Refinement: <source label>`
|
- The new title is derived from the first meaningful line of the operator's feedback; the full feedback remains in the description.
|
||||||
- New task depends on source task
|
- The new task depends on the source task and keeps `sourceType: "task_refine"` lineage.
|
||||||
- Created in `planning`
|
- 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.
|
||||||
- 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 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
|
## Archive and Restore
|
||||||
|
|
||||||
|
|||||||
@@ -2681,7 +2681,7 @@ describe("runTaskRefine", () => {
|
|||||||
mockRefineTask = vi.fn().mockResolvedValue({
|
mockRefineTask = vi.fn().mockResolvedValue({
|
||||||
id: "FN-002",
|
id: "FN-002",
|
||||||
description: "Refinement of FN-001",
|
description: "Refinement of FN-001",
|
||||||
column: "triage",
|
column: "todo",
|
||||||
dependencies: ["FN-001"],
|
dependencies: ["FN-001"],
|
||||||
steps: [],
|
steps: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
@@ -2717,6 +2717,13 @@ describe("runTaskRefine", () => {
|
|||||||
expect(successLine![0]).toContain("FN-002");
|
expect(successLine![0]).toContain("FN-002");
|
||||||
expect(successLine![0]).toContain("FN-001");
|
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
|
// Check that dependency is printed
|
||||||
const depLine = logSpy.mock.calls.find(
|
const depLine = logSpy.mock.calls.find(
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("Dependency:"),
|
(call) => typeof call[0] === "string" && call[0].includes("Dependency:"),
|
||||||
|
|||||||
@@ -1525,7 +1525,7 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam
|
|||||||
|
|
||||||
console.log();
|
console.log();
|
||||||
console.log(` ✓ Created refinement ${newTask.id} for ${id}`);
|
console.log(` ✓ Created refinement ${newTask.id} for ${id}`);
|
||||||
console.log(` Column: triage`);
|
console.log(` Column: ${newTask.column}`);
|
||||||
console.log(` Dependency: ${id}`);
|
console.log(` Dependency: ${id}`);
|
||||||
console.log(` Path: .fusion/tasks/${newTask.id}/`);
|
console.log(` Path: .fusion/tasks/${newTask.id}/`);
|
||||||
console.log();
|
console.log();
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
* async layer, so both surfaces must persist against PostgreSQL.
|
* async layer, so both surfaces must persist against PostgreSQL.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
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 {
|
import {
|
||||||
pgDescribe,
|
pgDescribe,
|
||||||
createTaskStoreForTest,
|
createTaskStoreForTest,
|
||||||
@@ -63,6 +66,133 @@ pgDescribe("refineTask / duplicateTask backend mode (PostgreSQL)", () => {
|
|||||||
* FN-8188 requires refinements to use the same project-default optional-group
|
* FN-8188 requires refinements to use the same project-default optional-group
|
||||||
* seed and persisted selection as createTask, including empty and absent defaults.
|
* 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 () => {
|
it("refineTask inherits default-on workflow groups and selection like createTask", async () => {
|
||||||
const h = await makeHarness();
|
const h = await makeHarness();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
|
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 {
|
import {
|
||||||
pgDescribe,
|
pgDescribe,
|
||||||
createSharedPgTaskStoreTestHarness,
|
createSharedPgTaskStoreTestHarness,
|
||||||
@@ -151,6 +154,35 @@ pgTest("TaskStore addComment steering + refinement (PostgreSQL)", () => {
|
|||||||
expect(after.some((t: { description?: string }) => (t.description ?? "").includes("empty case"))).toBe(true);
|
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 () => {
|
it("adds a steering comment and persists it", async () => {
|
||||||
const store = h.store();
|
const store = h.store();
|
||||||
const task = await store.createTask({ description: "steering target" });
|
const task = await store.createTask({ description: "steering target" });
|
||||||
|
|||||||
@@ -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):
|
FNXC:RefinementPlanningRouting 2026-08-19-05:26:
|
||||||
Resolve the inherited workflow's intake lane instead of the legacy `"triage"` literal. The
|
Refinements should be actionable immediately, so a workflow's manual capture lane is bypassed
|
||||||
hardcoded id landed refinements in a column the merged coding workflow no longer declares —
|
when that workflow declares a Planning hold. Automatic workflows retain their intake destination;
|
||||||
surfaced on the live board as an amber PLANNING badge (badge color keys off the raw column id)
|
a manual workflow without a usable hold, or any unresolved workflow, preserves the existing
|
||||||
on a card invisible to trait-driven sweeps until the undeclared-column re-home. Literal survives
|
`triage` fallback. Resolve these facts with the same pending selection that is persisted below so
|
||||||
only as the last resort for a store that cannot resolve any workflow, matching createTask.
|
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() }, {
|
const newTask = await store.createTaskWithDistributedReservation({ description: feedback.trim() }, {
|
||||||
createTaskWithId: async (newId) => {
|
createTaskWithId: async (newId) => {
|
||||||
// FN-5077: keep deterministic "Refinement" fallback when normalized refinement label is unusable (null).
|
// FN-5077: keep deterministic "Refinement" fallback when normalized refinement label is unusable (null).
|
||||||
|
|||||||
@@ -601,7 +601,7 @@ describe("refineTask", () => {
|
|||||||
const FAKE_REFINED_TASK: Task = {
|
const FAKE_REFINED_TASK: Task = {
|
||||||
id: "FN-002",
|
id: "FN-002",
|
||||||
description: "Refinement of FN-001",
|
description: "Refinement of FN-001",
|
||||||
column: "triage",
|
column: "todo",
|
||||||
dependencies: ["FN-001"],
|
dependencies: ["FN-001"],
|
||||||
steps: [],
|
steps: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
@@ -616,7 +616,8 @@ describe("refineTask", () => {
|
|||||||
const result = await refineTask("FN-001", "Need to add more tests and improve error handling");
|
const result = await refineTask("FN-001", "Need to add more tests and improve error handling");
|
||||||
|
|
||||||
expect(result.id).toBe("FN-002");
|
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(result.dependencies).toContain("FN-001");
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/refine", {
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/refine", {
|
||||||
headers: API_JSON_HEADERS,
|
headers: API_JSON_HEADERS,
|
||||||
|
|||||||
Reference in New Issue
Block a user