FN-7556: auto-select review-heavy workflow for AI-undo tasks

AI-undo tasks now default to a configurable, stricter review workflow instead of always inheriting the project default.

- Add project setting `aiUndoTaskWorkflowId` (default `builtin:review-heavy`) to ProjectSettings type and DEFAULT_PROJECT_SETTINGS
- `POST /api/tasks/:id/revert` resolves and validates the configured workflow id (via `isBuiltinWorkflowId`/`getWorkflowDefinition`), falling back to inherit-with-warning on a blank/unknown value
- `createAiUndoTask` engine helper gains an optional `workflowId` param, forwarded verbatim to `createTask` only when non-blank, staying pure (no settings/store access itself)
- Add regression tests for the route resolution logic and the engine helper's workflow forwarding
- Update docs (`settings-reference.md`, `task-management.md`) and add changeset

Files changed:
 .changeset/fn-7556-ai-undo-workflow.md             |  7 +++
 docs/settings-reference.md                         |  1 +
 docs/task-management.md                            |  1 +
 packages/core/src/settings-schema.ts               |  4 ++
 packages/core/src/types.ts                         | 14 +++++
 .../settings-default-descriptions.test.tsx         |  2 +
 .../src/__tests__/task-revert-route.test.ts        | 52 ++++++++++++++++-
 .../src/routes/register-task-workflow-routes.ts    | 31 +++++++++-
 .../src/__tests__/task-revert-ai-undo.test.ts      | 67 ++++++++++++++++++++++
 packages/engine/src/task-revert.ts                 | 16 ++++++
 10 files changed, 192 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7556
Fusion-Task-Lineage: dec5603c-10a8-4780-a1b8-8a836a0de4c1
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 10:27:03 -07:00
parent 2df6c356fc
commit 94e9d15e38
10 changed files with 192 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: AI-undo tasks now default to a configurable, stricter review workflow.
category: feature
dev: New project setting `aiUndoTaskWorkflowId` (default `builtin:review-heavy`) selects the workflow for AI-undo board tasks created by `POST /api/tasks/:id/revert` (`mode: "ai"`, the `auto` conflict fallback, and the workspace conflict fallback all share the `createAiUndoResult()` closure, so all three inherit this default). A blank/unset value means the created task inherits the project default workflow (pre-FN-7556 behavior). The route validates the configured id via `getWorkflowDefinition`/`isBuiltinWorkflowId` and falls back to inherit (with a logged warning) on a blank or unknown value, so a misconfigured id never breaks AI-undo task creation. The engine's `createAiUndoTask` helper stays pure — it only forwards a `workflowId` it is given, never resolves the setting itself. The Settings Modal UI field for this setting is a deliberate follow-up task; the value is settable today only via the settings API.

View File

@@ -412,6 +412,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF
| `secretsEnv` | `{ enabled?: boolean; filename?: string; overwritePolicy?: "skip" \| "merge" \| "replace"; keyPrefix?: string; requireGitignored?: boolean }` | `undefined` | Per-project secrets `.env` materialization configuration. When `enabled`, the engine writes `secretsEnv.filename` (default `.env`) into each acquired task worktree from secrets marked `env_exportable=true`. `overwritePolicy` controls merge/skip/replace against an existing file; `requireGitignored` (default `true`) refuses to write a non-gitignored path; `keyPrefix` filters which exported keys are included. See [Secrets](./secrets.md#env-auto-write-into-worktrees). | | `secretsEnv` | `{ enabled?: boolean; filename?: string; overwritePolicy?: "skip" \| "merge" \| "replace"; keyPrefix?: string; requireGitignored?: boolean }` | `undefined` | Per-project secrets `.env` materialization configuration. When `enabled`, the engine writes `secretsEnv.filename` (default `.env`) into each acquired task worktree from secrets marked `env_exportable=true`. `overwritePolicy` controls merge/skip/replace against an existing file; `requireGitignored` (default `true`) refuses to write a non-gitignored path; `keyPrefix` filters which exported keys are included. See [Secrets](./secrets.md#env-auto-write-into-worktrees). |
| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Project-scoped MCP server settings. Project entries override global entries by `name`; `enabled:false` on a same-named project entry disables the inherited global server. Sensitive env/header/token material must be Fusion secret references only. See [MCP server settings](#mcp-server-settings). | | `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Project-scoped MCP server settings. Project entries override global entries by `name`; `enabled:false` on a same-named project entry disables the inherited global server. Sensitive env/header/token material must be Fusion secret references only. See [MCP server settings](#mcp-server-settings). |
| `owningNodeHandoffPolicy` | `"block" \| "reassign-to-local" \| "reassign-any-healthy"` | `"reassign-to-local"` | Policy for tasks already checked out by an unavailable owning node. `"block"` parks, `"reassign-to-local"` takes over on local node, `"reassign-any-healthy"` makes takeover eligible on healthy peers. | | `owningNodeHandoffPolicy` | `"block" \| "reassign-to-local" \| "reassign-any-healthy"` | `"reassign-to-local"` | Policy for tasks already checked out by an unavailable owning node. `"block"` parks, `"reassign-to-local"` takes over on local node, `"reassign-any-healthy"` makes takeover eligible on healthy peers. |
| `aiUndoTaskWorkflowId` | `string` | `"builtin:review-heavy"` | **FN-7556.** Workflow selected for AI-undo board tasks created by `POST /api/tasks/:id/revert` (`mode: "ai"` and the `auto`/workspace conflict fallbacks) — a stricter review posture since these tasks reverse already-shipped code. Blank/unset means the created task inherits the project default workflow. The route validates the configured id and falls back to inherit on a blank or unknown value, so a misconfigured id never breaks AI-undo task creation. Settings Modal UI for this field is a follow-up; today it is settable only through the settings API. See [Task Management → Reverting Done/Archived tasks](./task-management.md#reverting-donearchived-tasks-git-path--ai-undo-fallback). |
| `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. | | `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. |
| `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. | | `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. |

View File

@@ -687,6 +687,7 @@ Recovery/backfill guidance:
- **Workspace (multi-repo) tasks (FN-7547):** tasks with `workspaceWorktrees` populated (`isWorkspaceTask`) are revertable too — the route dispatches to a dedicated workspace path that reasons about every sub-repo's integration branch as ONE all-or-nothing unit. It resolves each sub-repo's attributable commit(s), dry-run classifies every sub-repo first, and only commits a `revert(FN-xxxx): ...` commit on EACH sub-repo when every sub-repo classifies clean/already-reverted; if any sub-repo conflicts, no sub-repo is committed and every touched sub-repo worktree is rolled back to its pre-call state. Response contract for workspace tasks: `{ mode: "git", clean, workspace: { repos: [{ repo, classification, revertCommitSha?, conflicts?, alreadyReverted? }] }, conflicts?: {repo, file, ...}[] }`. A conflicting workspace result still falls back to the AI-undo task under `"auto"` mode, same as a single-repo conflicting result. - **Workspace (multi-repo) tasks (FN-7547):** tasks with `workspaceWorktrees` populated (`isWorkspaceTask`) are revertable too — the route dispatches to a dedicated workspace path that reasons about every sub-repo's integration branch as ONE all-or-nothing unit. It resolves each sub-repo's attributable commit(s), dry-run classifies every sub-repo first, and only commits a `revert(FN-xxxx): ...` commit on EACH sub-repo when every sub-repo classifies clean/already-reverted; if any sub-repo conflicts, no sub-repo is committed and every touched sub-repo worktree is rolled back to its pre-call state. Response contract for workspace tasks: `{ mode: "git", clean, workspace: { repos: [{ repo, classification, revertCommitSha?, conflicts?, alreadyReverted? }] }, conflicts?: {repo, file, ...}[] }`. A conflicting workspace result still falls back to the AI-undo task under `"auto"` mode, same as a single-repo conflicting result.
- **`autoMerge:false` PR-based revert (FN-7554):** for a single-repo task whose git revert classifies **clean**, `autoMerge:false` no longer dead-ends at `needsHuman`. The route prepares a dedicated `fusion/revert-<id>` branch off the resolved base branch (via the engine's `prepareRevertPrBranch`, which NEVER writes to the base branch itself), pushes it, and opens a GitHub PR through the same owner/repo resolution, `githubRateLimiter` gate, `findPrForBranch` idempotency, and `manual: true` handoff as `POST /tasks/:id/pr/create`. Response: `{ mode: "pr", clean: true, prUrl, prNumber, revertBranch, existingPr? }` — a second call while the PR is still open links the existing PR (`existingPr: true`) instead of re-pushing. GitHub unconfigured or rate-limited still degrades gracefully to `{ mode: "git", needsHuman: true, reason }`, and a conflicting/unsupported/already-reverted classification is unaffected (no PR is opened; `"auto"` mode still falls back to the AI-undo task on conflict/unsupported). Workspace (multi-repo) tasks are not yet covered by this PR path — they keep the existing `needsHuman` result under `autoMerge:false`. - **`autoMerge:false` PR-based revert (FN-7554):** for a single-repo task whose git revert classifies **clean**, `autoMerge:false` no longer dead-ends at `needsHuman`. The route prepares a dedicated `fusion/revert-<id>` branch off the resolved base branch (via the engine's `prepareRevertPrBranch`, which NEVER writes to the base branch itself), pushes it, and opens a GitHub PR through the same owner/repo resolution, `githubRateLimiter` gate, `findPrForBranch` idempotency, and `manual: true` handoff as `POST /tasks/:id/pr/create`. Response: `{ mode: "pr", clean: true, prUrl, prNumber, revertBranch, existingPr? }` — a second call while the PR is still open links the existing PR (`existingPr: true`) instead of re-pushing. GitHub unconfigured or rate-limited still degrades gracefully to `{ mode: "git", needsHuman: true, reason }`, and a conflicting/unsupported/already-reverted classification is unaffected (no PR is opened; `"auto"` mode still falls back to the AI-undo task on conflict/unsupported). Workspace (multi-repo) tasks are not yet covered by this PR path — they keep the existing `needsHuman` result under `autoMerge:false`.
- **Dashboard auto-linking (FN-7555):** the AI-undo task's card shows an "Undo of FN-xxxx" chip and its detail view shows a clickable "Created to undo FN-xxxx" link back to the source task. The source task's detail view shows an "Undo task: FN-YYYY" link whenever an OPEN undo task referencing it exists in the loaded tasks (matching `TaskStore.findOpenRevertTaskForSource`'s open-only semantics — a `done`/`archived`/soft-deleted undo task is never surfaced as active). Both directions are derived client-side from `sourceMetadata.revertOf`; no new API. A dedicated Done/Archived card revert-trigger action is still a separate follow-up (see FN-7525). - **Dashboard auto-linking (FN-7555):** the AI-undo task's card shows an "Undo of FN-xxxx" chip and its detail view shows a clickable "Created to undo FN-xxxx" link back to the source task. The source task's detail view shows an "Undo task: FN-YYYY" link whenever an OPEN undo task referencing it exists in the loaded tasks (matching `TaskStore.findOpenRevertTaskForSource`'s open-only semantics — a `done`/`archived`/soft-deleted undo task is never surfaced as active). Both directions are derived client-side from `sourceMetadata.revertOf`; no new API. A dedicated Done/Archived card revert-trigger action is still a separate follow-up (see FN-7525).
- **Configurable AI-undo workflow default (FN-7556):** the project setting `aiUndoTaskWorkflowId` (default `builtin:review-heavy`) selects the workflow applied to every AI-undo task created above (`mode:"ai"` and the `auto`/workspace conflict fallbacks all share one creation seam, so all three inherit this default) — a stricter review posture is warranted because these tasks reverse already-shipped code. A blank/unset value means the created task inherits the project default workflow (pre-FN-7556 behavior); the route falls back to inherit (with a logged warning) if the configured id is blank or does not resolve to a real workflow, so a misconfigured id never breaks AI-undo task creation. See [Settings Reference → Project Settings](./settings-reference.md#project-settings). The Settings Modal UI field for this setting is a deliberate follow-up; it is settable today only via the settings API.
## GitHub Issue Import and PR Creation ## GitHub Issue Import and PR Creation

View File

@@ -296,6 +296,10 @@ export const DEFAULT_PROJECT_SETTINGS = {
globalPause: false, globalPause: false,
globalPauseReason: undefined, globalPauseReason: undefined,
defaultWorkflowId: undefined, defaultWorkflowId: undefined,
// FNXC:TaskRevert 2026-07-05-00:00 (FN-7556): AI-undo tasks reverse
// already-shipped code, so default them to the stricter review-heavy
// workflow; empty/unset means inherit the project default workflow.
aiUndoTaskWorkflowId: "builtin:review-heavy",
enabledBuiltinWorkflowIds: undefined, enabledBuiltinWorkflowIds: undefined,
approvedWorkflowCliCommands: undefined, approvedWorkflowCliCommands: undefined,
approvedCliAutonomyAdapters: undefined, approvedCliAutonomyAdapters: undefined,

View File

@@ -3673,6 +3673,20 @@ export interface ProjectSettings {
/** Default custom workflow (WF-…) applied to newly created tasks when the /** Default custom workflow (WF-…) applied to newly created tasks when the
* caller does not specify enabledWorkflowSteps. Overridable per task. */ * caller does not specify enabledWorkflowSteps. Overridable per task. */
defaultWorkflowId?: string; defaultWorkflowId?: string;
/**
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7556):
* Workflow selected for AI-undo board tasks (`createAiUndoTask`, engine
* `task-revert.ts`) — these tasks surgically reverse ALREADY-SHIPPED code
* while preserving unrelated later changes to the same files, so they
* warrant a stricter default review posture than ordinary new work.
* Defaults to `builtin:review-heavy` (see `DEFAULT_PROJECT_SETTINGS`).
* Empty/unset means AI-undo tasks inherit the project default workflow
* (today's pre-FN-7556 behavior). The route resolving this setting
* (`POST /api/tasks/:id/revert`) validates the configured id exists and
* falls back to inherit (undefined) on a blank/unknown value so a
* misconfigured id never breaks AI-undo task creation.
*/
aiUndoTaskWorkflowId?: string;
/** Built-in workflow ids visible/selectable in project workflow pickers. /** Built-in workflow ids visible/selectable in project workflow pickers.
* Undefined preserves the default of showing every built-in workflow. */ * Undefined preserves the default of showing every built-in workflow. */
enabledBuiltinWorkflowIds?: string[]; enabledBuiltinWorkflowIds?: string[];

View File

@@ -468,6 +468,8 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
prerebaseDivergenceThreshold: "internal pre-rebase tuning constant, no UI field", prerebaseDivergenceThreshold: "internal pre-rebase tuning constant, no UI field",
maxSpawnedAgentsPerParent: "internal spawn-limit constant, no UI field", maxSpawnedAgentsPerParent: "internal spawn-limit constant, no UI field",
maxSpawnedAgentsGlobal: "internal spawn-limit constant, no UI field", maxSpawnedAgentsGlobal: "internal spawn-limit constant, no UI field",
// FN-7556: AI-undo workflow default — Settings UI is a follow-up task.
aiUndoTaskWorkflowId: "AI-undo workflow default — Settings UI is a follow-up task",
}; };
describe("FN-7505 settings default-value description guard", () => { describe("FN-7505 settings default-value description guard", () => {

View File

@@ -108,7 +108,13 @@ function makeWorkspaceTask(overrides: Partial<Task>): Task {
function createMockStore( function createMockStore(
task: Task, task: Task,
opts?: { openUndoTask?: Task | null; createdUndoTask?: Task; autoMerge?: boolean }, opts?: {
openUndoTask?: Task | null;
createdUndoTask?: Task;
autoMerge?: boolean;
aiUndoTaskWorkflowId?: string;
knownWorkflowIds?: string[];
},
): TaskStore { ): TaskStore {
let nextId = 800; let nextId = 800;
const createTask = vi.fn().mockImplementation(async (input: { description: string; source?: { sourceParentTaskId?: string; sourceMetadata?: Record<string, unknown> } }) => { const createTask = vi.fn().mockImplementation(async (input: { description: string; source?: { sourceParentTaskId?: string; sourceMetadata?: Record<string, unknown> } }) => {
@@ -126,9 +132,19 @@ function createMockStore(
return created; return created;
}); });
const findOpenRevertTaskForSource = vi.fn().mockResolvedValue(opts?.openUndoTask ?? null); const findOpenRevertTaskForSource = vi.fn().mockResolvedValue(opts?.openUndoTask ?? null);
// FN-7556: `getWorkflowDefinition` backs the route's validation of a
// configured (non-builtin) `aiUndoTaskWorkflowId`; default to "unknown" so
// tests must explicitly declare a custom id as known via `knownWorkflowIds`.
const getWorkflowDefinition = vi.fn().mockImplementation(async (id: string) =>
(opts?.knownWorkflowIds ?? []).includes(id) ? { id, name: id, ir: {} } : undefined,
);
return { return {
getSettings: vi.fn().mockResolvedValue({}), getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({ autoMerge: opts?.autoMerge ?? true }), getSettingsFast: vi.fn().mockResolvedValue({
autoMerge: opts?.autoMerge ?? true,
aiUndoTaskWorkflowId: opts?.aiUndoTaskWorkflowId,
}),
getWorkflowDefinition,
getRootDir: vi.fn().mockReturnValue(makeGitRepoOnMain()), getRootDir: vi.fn().mockReturnValue(makeGitRepoOnMain()),
getTask: vi.fn().mockResolvedValue(task), getTask: vi.fn().mockResolvedValue(task),
getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]), getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]),
@@ -399,6 +415,38 @@ describe("POST /tasks/:id/revert — FN-7524 mode + AI-undo fallback", () => {
expect(store.createTask as ReturnType<typeof vi.fn>).not.toHaveBeenCalled(); expect(store.createTask as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
}); });
it("(FN-7556) forwards the configured aiUndoTaskWorkflowId (default review-heavy) into the created AI-undo task", async () => {
const task = makeTask({ id: "FN-970", column: "done" });
const store = createMockStore(task, { aiUndoTaskWorkflowId: "builtin:review-heavy" });
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" });
expect(res.status).toBe(200);
expect(store.createTask as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
const createInput = (store.createTask as ReturnType<typeof vi.fn>).mock.calls[0][0] as { workflowId?: string };
expect(createInput.workflowId).toBe("builtin:review-heavy");
});
it("(FN-7556) a blank/unset aiUndoTaskWorkflowId omits workflowId so the task inherits the project default", async () => {
const task = makeTask({ id: "FN-971", column: "done" });
const store = createMockStore(task, { aiUndoTaskWorkflowId: "" });
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" });
expect(res.status).toBe(200);
const createInput = (store.createTask as ReturnType<typeof vi.fn>).mock.calls[0][0] as { workflowId?: string };
expect("workflowId" in createInput).toBe(false);
});
it("(FN-7556) an unknown configured workflow id falls back to inherit (task still created, no 500)", async () => {
const task = makeTask({ id: "FN-972", column: "done" });
const store = createMockStore(task, { aiUndoTaskWorkflowId: "WF-does-not-exist", knownWorkflowIds: [] });
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "ai" });
expect(res.status).toBe(200);
expect(store.createTask as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
const createInput = (store.createTask as ReturnType<typeof vi.fn>).mock.calls[0][0] as { workflowId?: string };
expect("workflowId" in createInput).toBe(false);
});
it("(d) auto + clean: returns the git result and does NOT create an AI-undo task", async () => { it("(d) auto + clean: returns the git result and does NOT create an AI-undo task", async () => {
const task = makeTask({ id: "FN-904", column: "done" }); const task = makeTask({ id: "FN-904", column: "done" });
const store = createMockStore(task); const store = createMockStore(task);

View File

@@ -45,6 +45,7 @@ import {
isWorkflowColumnsEnabled, isWorkflowColumnsEnabled,
TransitionRejectionError, TransitionRejectionError,
getPlannerInterventionTimeline, getPlannerInterventionTimeline,
isBuiltinWorkflowId,
type NearDuplicateCandidate, type NearDuplicateCandidate,
} from "@fusion/core"; } from "@fusion/core";
import { GitHubClient } from "../github.js"; import { GitHubClient } from "../github.js";
@@ -1744,11 +1745,40 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
granularity = requestedGranularity; granularity = requestedGranularity;
} }
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7556):
`settings` is fetched HERE (before `createAiUndoResult` is defined/used)
because the `mode === "ai"` early-return path below uses the closure
before the git-path `settings` fetch that used to follow it. AI-undo
tasks default to the `aiUndoTaskWorkflowId` project setting (default
`builtin:review-heavy` — a stricter review posture than ordinary new
work, since these tasks reverse already-shipped code). A blank/whitespace
value means inherit the project default workflow; a non-blank value that
does not resolve to a real workflow (custom or builtin) is logged and
falls back to inherit too — a misconfigured id must never break AI-undo
task creation.
*/
const settings = await scopedStore.getSettingsFast();
const configuredAiUndoWorkflowId = settings.aiUndoTaskWorkflowId?.trim();
let aiUndoWorkflowId: string | undefined;
if (configuredAiUndoWorkflowId) {
const exists =
isBuiltinWorkflowId(configuredAiUndoWorkflowId) || Boolean(await scopedStore.getWorkflowDefinition(configuredAiUndoWorkflowId));
if (exists) {
aiUndoWorkflowId = configuredAiUndoWorkflowId;
} else {
console.warn(
`[task-revert] aiUndoTaskWorkflowId "${configuredAiUndoWorkflowId}" does not resolve to a known workflow; AI-undo task will inherit the project default workflow instead`,
);
}
}
const createAiUndoResult = async (): Promise<AiUndoTaskResult> => const createAiUndoResult = async (): Promise<AiUndoTaskResult> =>
createAiUndoTask({ createAiUndoTask({
createTask: (input) => scopedStore.createTask(input), createTask: (input) => scopedStore.createTask(input),
findOpenRevertTaskForSource: (id) => scopedStore.findOpenRevertTaskForSource(id), findOpenRevertTaskForSource: (id) => scopedStore.findOpenRevertTaskForSource(id),
sourceTask: task, sourceTask: task,
workflowId: aiUndoWorkflowId,
}); });
if (mode === "ai") { if (mode === "ai") {
@@ -1757,7 +1787,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
} }
const rootDir = scopedStore.getRootDir(); const rootDir = scopedStore.getRootDir();
const settings = await scopedStore.getSettingsFast();
/* /*
FNXC:TaskRevert 2026-07-04-00:00 (FN-7547 — workspace dispatch): FNXC:TaskRevert 2026-07-04-00:00 (FN-7547 — workspace dispatch):

View File

@@ -111,4 +111,71 @@ describe("createAiUndoTask (FN-7524)", () => {
expect(result).toEqual({ mode: "ai", createdTaskId: "FN-955", alreadyOpen: true }); expect(result).toEqual({ mode: "ai", createdTaskId: "FN-955", alreadyOpen: true });
expect(createTask).not.toHaveBeenCalled(); expect(createTask).not.toHaveBeenCalled();
}); });
it("forwards a non-blank workflowId into the createTask input (FN-7556)", async () => {
const createTask = vi.fn(async (input: TaskCreateInput) => makeExistingTask({
id: "FN-961",
description: input.description,
dependencies: input.dependencies ?? [],
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: input.source?.sourceMetadata,
}));
const findOpenRevertTaskForSource = vi.fn(async () => null);
const result = await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
workflowId: "builtin:review-heavy",
});
expect(result).toEqual({ mode: "ai", createdTaskId: "FN-961" });
const input = createTask.mock.calls[0][0] as TaskCreateInput;
expect(input.workflowId).toBe("builtin:review-heavy");
});
it("omits the workflowId key entirely when no/blank workflowId is supplied (inherit project default, FN-7556)", async () => {
const createTask = vi.fn(async (input: TaskCreateInput) => makeExistingTask({
id: "FN-962",
description: input.description,
dependencies: input.dependencies ?? [],
sourceParentTaskId: input.source?.sourceParentTaskId,
sourceMetadata: input.source?.sourceMetadata,
}));
const findOpenRevertTaskForSource = vi.fn(async () => null);
await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
});
const noWorkflowIdInput = createTask.mock.calls[0][0] as TaskCreateInput;
expect("workflowId" in noWorkflowIdInput).toBe(false);
createTask.mockClear();
await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
workflowId: " ",
});
const blankWorkflowIdInput = createTask.mock.calls[0][0] as TaskCreateInput;
expect("workflowId" in blankWorkflowIdInput).toBe(false);
});
it("never creates a task on the idempotent alreadyOpen path regardless of workflowId (FN-7556)", async () => {
const createTask = vi.fn();
const existing = makeExistingTask({ id: "FN-963", column: "triage" });
const findOpenRevertTaskForSource = vi.fn(async () => existing);
const result = await createAiUndoTask({
createTask,
findOpenRevertTaskForSource,
sourceTask: makeSourceTask(),
workflowId: "builtin:review-heavy",
});
expect(result).toEqual({ mode: "ai", createdTaskId: "FN-963", alreadyOpen: true });
expect(createTask).not.toHaveBeenCalled();
});
}); });

View File

@@ -1307,6 +1307,17 @@ export interface CreateAiUndoTaskDeps {
/** Idempotency lookup — see `REVERT_OF_METADATA_KEY`. Implemented by `TaskStore.findOpenRevertTaskForSource` (core). */ /** Idempotency lookup — see `REVERT_OF_METADATA_KEY`. Implemented by `TaskStore.findOpenRevertTaskForSource` (core). */
findOpenRevertTaskForSource(sourceTaskId: string): Promise<Task | null>; findOpenRevertTaskForSource(sourceTaskId: string): Promise<Task | null>;
sourceTask: Pick<Task, "id" | "title" | "description" | "prompt" | "mergeDetails" | "priority">; sourceTask: Pick<Task, "id" | "title" | "description" | "prompt" | "mergeDetails" | "priority">;
/**
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7556):
* Workflow id to select for the created AI-undo task, forwarded verbatim
* into `createTask({ workflowId })`. The CALLER (the route) resolves and
* validates this from the `aiUndoTaskWorkflowId` project setting (default
* `builtin:review-heavy`) — this helper stays pure and never reads
* settings/the store itself. A blank/undefined value means the created
* task INHERITS the project default workflow (`createTask`'s `undefined`
* semantics) — do NOT pass `null` here, which would mean "no workflow".
*/
workflowId?: string;
} }
/** /**
@@ -1326,6 +1337,10 @@ export async function createAiUndoTask(deps: CreateAiUndoTaskDeps): Promise<AiUn
} }
const description = buildAiUndoTaskDescription({ task: sourceTask }); const description = buildAiUndoTaskDescription({ task: sourceTask });
// FNXC:TaskRevert 2026-07-05-00:00 (FN-7556): forward `workflowId` ONLY when
// a non-empty string is supplied; otherwise omit the key entirely so
// `createTask` inherits the project default (never pass `null`/"" through).
const workflowId = deps.workflowId && deps.workflowId.trim() !== "" ? deps.workflowId : undefined;
const created = await deps.createTask({ const created = await deps.createTask({
title: `Undo ${sourceTask.id}: ${sourceTask.title ?? sourceTask.description.slice(0, 80)}`, title: `Undo ${sourceTask.id}: ${sourceTask.title ?? sourceTask.description.slice(0, 80)}`,
description, description,
@@ -1335,6 +1350,7 @@ export async function createAiUndoTask(deps: CreateAiUndoTaskDeps): Promise<AiUn
sourceType: "recovery", sourceType: "recovery",
sourceMetadata: { [REVERT_OF_METADATA_KEY]: sourceTask.id }, sourceMetadata: { [REVERT_OF_METADATA_KEY]: sourceTask.id },
}, },
...(workflowId !== undefined ? { workflowId } : {}),
}); });
return { mode: "ai", createdTaskId: created.id }; return { mode: "ai", createdTaskId: created.id };