diff --git a/.changeset/fn-7985-review-budget-approval.md b/.changeset/fn-7985-review-budget-approval.md new file mode 100644 index 0000000000..448a488e32 --- /dev/null +++ b/.changeset/fn-7985-review-budget-approval.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Show when Plan Review budget exhaustion needs approval and make the replan cap configurable. +category: feature +dev: Adds a number-typed workflow setting (unset → falls back to PLAN_REVIEW_GATE_REPLAN_CAP) read in triage blockAfterPlanReviewRevise; adds a distinct TaskCard/ListView badge + TaskDetailModal callout gated on awaitingApprovalReason === "plan-review-replan-cap". diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 027cbe9bae..345599f138 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -298,7 +298,11 @@ Actions. It has two tabs: controls. Built-in Plan Review/spec and Code Review revision caps also live here: leave `planReviewMaxRevisions` or `codeReviewMaxRevisions` empty for unbounded automatic revisions, enter a non-negative integer to cap attempts, or enter `0` - to disable automatic revision for that path. `plannerOversightLevel` is the + to disable automatic revision for that path. The separate triage Plan Review + replan ceiling, `planReviewReplanCap`, controls consecutive pre-execution + `REVISE` → replan cycles before Fusion requests manual approval; leave it empty + to use the built-in engine default, or set a non-negative integer (including + `0`) for the workflow. `plannerOversightLevel` is the workflow-native planner oversight mode and accepts `off`, `observe`, `steer`, or `autonomous` (default). `plannerHeartbeatPatrolEnabled` controls idle/no-task heartbeat patrol task creation separately and defaults to `true`. Edits batch @@ -340,7 +344,7 @@ These groups moved out of project settings and into workflow settings (built-in | Group | Keys (examples) | |---|---| | **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` | -| **Review / approval** | Workflow values: `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, `planReviewMaxRevisions`, `codeReviewMaxRevisions`; project override: `planApprovalMode` | +| **Review / approval** | Workflow values: `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, `planReviewMaxRevisions`, `codeReviewMaxRevisions`, `planReviewReplanCap`; project override: `planApprovalMode` | | **Planner oversight** | `plannerOversightLevel` (workflow-native; values: `off`, `observe`, `steer`, `autonomous`); `plannerOversightNotificationLevel` (workflow-native; values: `silent`, `errors`, `important`, `all`); `plannerOverseerExecutorStuckAfterMs` (workflow-native; number, default `7200000` = 2h); `plannerOverseerAdvisorEnabled` (boolean, **default false**); `plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId` (session-advisor model; both required when enabled); `plannerHeartbeatPatrolEnabled` (workflow-native; boolean, default `true`, gates idle/no-task heartbeat patrol task creation) | | **Per-phase model lanes** | `executionProvider`/`executionModelId` + `executionThinkingLevel`, `planningProvider`/`planningModelId` + `planningThinkingLevel` (+ fallbacks), `validatorProvider`/`validatorModelId` + `validatorThinkingLevel` (+ fallbacks). Thinking values accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset inherits. | @@ -378,6 +382,7 @@ The built-in workflows also declare triage/spec policy settings that were **not* | `autoApproveSpec` | `false` | Legacy compatibility setting. Workflow Plan Review now owns optional pre-execution AI plan approval. | | `planReviewMaxRevisions` | unset | Workflow-native Plan Review/spec revision cap. Unset/empty means unbounded automatic replans; a non-negative integer caps attempts; `0` disables automatic Plan Review revision. | | `codeReviewMaxRevisions` | unset | Workflow-native Code Review remediation cap. Unset/empty means unbounded automatic code-fix passes; a non-negative integer caps attempts; `0` disables automatic Code Review remediation. | +| `planReviewReplanCap` | unset | Workflow-native triage Plan Review replan ceiling. It bounds consecutive pre-execution Plan Review `REVISE` → replan cycles before manual approval; unset/empty uses the built-in engine default, and a non-negative integer (including `0`) overrides it. | | `plannerOversightLevel` | `autonomous` | Workflow-native planner oversight mode. `off` disables oversight; `observe` watches only; `steer` injects guidance or suggests revisions; `autonomous` enables bounded retry and targeted-fix recovery — but merge/PR progression and any destructive or external-service side effect ALWAYS require an explicit, recorded human confirmation before they run, even at `autonomous` (FN-7513's confirmation gate; see `docs/architecture.md` → "Planner overseer confirmation gate"). Tasks may set a nullable `Task.plannerOversightLevel` override (same four values) that wins over this workflow value when present; `null`/unset means "inherit the workflow value". `resolveEffectivePlannerOversightLevel` in `@fusion/core` computes the effective level (task override → workflow effective → `autonomous`). The per-task override is exposed in the dashboard as a "Planner oversight" selector (Inherit from workflow / Off / Observe / Steer / Autonomous recovery) in both the New Task dialog and Task Detail edit form, threaded through `createTask`/`updateTask` (FN-7515); the project/global default is set via the **Workflow Editor → Values** tab on the default workflow's `plannerOversightLevel` value, not in Project Settings. FN-7517 additionally exposes a quick inline oversight-level select in the Task Detail modal's meta-controls cluster (same `updateTask` override plumbing, no parallel path) plus manual nudge/stop-oversight/explain-current-action controls that call the overseer runtime directly — see `docs/dashboard-guide.md`. Engine read-site behavior beyond the FN-7513 confirmation gate remains follow-up work (FN-7510+). | | `plannerOversightNotificationLevel` | `important` | Workflow-native planner-overseer notification verbosity (FN-7518). `silent` suppresses overseer notifications; `errors` notifies only on failures/escalations; `important` (the default) notifies on interventions/recovery actions and errors; `all` notifies on every observation. Resolves through the generic `resolveEffectiveSettings` default path with no special-casing, alongside `plannerOversightLevel`. This is a declaration-only setting: the notification-emission gating that reads it lands downstream in FN-7519 (intervention timeline) and FN-7520 (run-audit/activity events). | | `plannerOverseerAdvisorEnabled` | `false` | Master switch for the planner overseer **session advisor** (live LLM transcript review). **Off by default.** When false, no second-model advisor runs regardless of model fields or `plannerOversightLevel`. Lifecycle stage watching, stall recovery, and merge confirmation are unaffected. | diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 5943d494b5..446410eb49 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -419,7 +419,7 @@ Built-in optional gates ship as inlined IR builders, not as a template catalog: - `builtin:coding` carries the `browser-verification` optional-group node (`builtin-browser-verification-group.ts`), default-off, so browser verification runs only for tasks whose `enabledWorkflowSteps` includes `browser-verification`. - `builtin:coding` and `builtin:stepwise-coding` carry the `plan-review` optional-group node (`builtin-plan-review-group.ts`), default-on, before `parse-steps` so the plan can be reviewed before execution begins even when a task has not persisted explicit optional-step ids. - The `code-review` optional-group node (`builtin-code-review-group.ts`) is the inlined default-on code-review gate. On default `builtin:coding`, this is the only final review surface before merge; it is effective by default even when no explicit optional-step ids are stored. On `builtin:stepwise-coding`, it remains a post-foreach optional final review gate before the workflow's final review seam. -- Plan Review/spec revision and Code Review remediation loops are unbounded by default for built-in workflows, including Compound Engineering's CE Code Review path. Operators can cap them per workflow from the Workflow Editor **Settings → Values** tab with `planReviewMaxRevisions` and `codeReviewMaxRevisions`; leaving the value empty means unbounded, a non-negative integer caps automatic revisions, and `0` disables automatic revision for that review path. These values are editable for read-only built-ins without duplicating the workflow. +- Plan Review/spec revision and Code Review remediation loops are unbounded by default for built-in workflows, including Compound Engineering's CE Code Review path. Operators can cap them per workflow from the Workflow Editor **Settings → Values** tab with `planReviewMaxRevisions` and `codeReviewMaxRevisions`; leaving the value empty means unbounded, a non-negative integer caps automatic revisions, and `0` disables automatic revision for that review path. Separately, `planReviewReplanCap` bounds consecutive **triage pre-execution** Plan Review `REVISE` → replan cycles before Fusion parks the task for an explicit human Approve or Reject decision. Leave that setting empty to use the built-in engine default; a non-negative integer (including `0`) overrides it. These values are editable for read-only built-ins without duplicating the workflow. - A workflow (for example compound-engineering) can add a **post-merge** optional-group node via the generic `postMergeOptionalGroupNode(...)` builder (`builtin-post-merge-group.ts`) — e.g. a `document` step that runs after merge. Create-time optional-step controls appear in the quick-add action row and the **New Task** dialog inline quick buttons for the active workflow. They resolve the workflow's optional-group nodes (plus plugin-contributed palette templates, see [Plugin-Contributed Steps](#plugin-contributed-steps)) into toggleable rows. Selecting **Fast** clears currently enabled optional steps and submits `enabledWorkflowSteps: []` even if optional-step metadata is still loading, but the dropdown stays available once loaded; any manual reselection before create is submitted as explicit ids and executes even on the Fast task. Workflows with no optional groups render no trigger and omit `enabledWorkflowSteps` unless the operator selects Fast, where the explicit empty array preserves the speed-first opt-out. Unknown or removed ids are skipped during resolution so stale selections never render blank controls or break workflow loading. diff --git a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts index 60b5ff3ad6..7ffe41bdc9 100644 --- a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts +++ b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts @@ -63,6 +63,7 @@ describe("workflow-native built-in workflow settings", () => { "reviewerInlineFixes", "planReviewMaxRevisions", "codeReviewMaxRevisions", + "planReviewReplanCap", ]); const inlineFixes = revisionById.get("reviewerInlineFixes"); expect(inlineFixes).toMatchObject({ @@ -72,12 +73,15 @@ describe("workflow-native built-in workflow settings", () => { expect(fullIds.has("reviewerInlineFixes")).toBe(true); expect(movedIds.has("reviewerInlineFixes")).toBe(false); expect(movedKeyIds.has("reviewerInlineFixes")).toBe(false); - for (const id of ["planReviewMaxRevisions", "codeReviewMaxRevisions"]) { + for (const id of ["planReviewMaxRevisions", "codeReviewMaxRevisions", "planReviewReplanCap"]) { const setting = revisionById.get(id); expect(setting, `${id} should be declared`).toBeDefined(); expect(setting?.type).toBe("number"); expect(setting).not.toHaveProperty("default"); - expect(setting?.description).toMatch(/Leave unset for unbounded|Leave unset|unbounded/i); + if (id === "planReviewReplanCap") { + expect(setting).toMatchObject({ minimum: 0, integer: true }); + } + expect(setting?.description).toMatch(/Leave unset|unset|unbounded/i); expect(setting?.description).toContain("0"); expect(fullIds.has(id), `${id} should be in the full built-in catalog`).toBe(true); expect(movedIds.has(id), `${id} should not be in the moved-key catalog`).toBe(false); @@ -85,6 +89,25 @@ describe("workflow-native built-in workflow settings", () => { } }); + it("rejects fractional and negative values for the Plan Review replan cap", () => { + const invalid = validateSettingValuePatch(BUILTIN_REVIEW_REVISION_SETTINGS, { + planReviewReplanCap: -1, + }); + expect(invalid.rejections).toEqual([ + expect.objectContaining({ settingId: "planReviewReplanCap", code: "type-mismatch" }), + ]); + + const fractionalCap = validateSettingValuePatch(BUILTIN_REVIEW_REVISION_SETTINGS, { + planReviewReplanCap: 2.5, + }); + expect(fractionalCap.rejections).toEqual([ + expect.objectContaining({ settingId: "planReviewReplanCap", code: "type-mismatch" }), + ]); + expect(validateSettingValuePatch(BUILTIN_REVIEW_REVISION_SETTINGS, { + planReviewReplanCap: 0, + }).accepted).toEqual({ planReviewReplanCap: 0 }); + }); + it("declares planner oversight level as a workflow-native enum outside moved/project settings", () => { const fullIds = new Set(BUILTIN_WORKFLOW_SETTINGS.map((setting) => setting.id)); const movedIds = new Set(BUILTIN_MOVED_WORKFLOW_SETTINGS.map((setting) => setting.id)); diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index a7f4e336ec..446251cca4 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -469,6 +469,23 @@ export const BUILTIN_REVIEW_REVISION_SETTINGS: WorkflowSettingDefinition[] = [ description: "Maximum automatic Code Review remediation attempts for this workflow. Leave unset for unbounded; set 0 to disable automatic revision.", }, + { + id: "planReviewReplanCap", + name: "Plan Review replan cap", + type: "number", + minimum: 0, + integer: true, + /* + * FNXC:WorkflowRevisionBudget 2026-07-15-12:00: + * FN-7985 makes the triage Plan Review replan ceiling operator-configurable per workflow. + * The write boundary rejects fractional and negative values so operators never save a + * value triage would discard. Leave this declaration without a default so an unset value falls back to + * PLAN_REVIEW_GATE_REPLAN_CAP; that preserves the source default while allowing its + * coordinated value to change without baking a second default into workflow settings. + */ + description: + "Maximum automatic plan → REVISE → replan iterations before manual approval. Leave unset to use the built-in default; set 0 to require approval after the first REVISE.", + }, ]; /** diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 7454094b5a..624e2abed6 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -506,6 +506,7 @@ export { resolveEffectivePlannerHeartbeatPatrolEnabled, PLAN_REVIEW_MAX_REVISIONS_SETTING_ID, CODE_REVIEW_MAX_REVISIONS_SETTING_ID, + PLAN_REVIEW_REPLAN_CAP_SETTING_ID, type WorkflowSettingsResolverStore, type EffectiveSettingsResult, type EffectiveSettingsTaskRef, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6f175f5bec..5bd5f348dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -495,6 +495,7 @@ export { resolveEffectivePlannerHeartbeatPatrolEnabled, PLAN_REVIEW_MAX_REVISIONS_SETTING_ID, CODE_REVIEW_MAX_REVISIONS_SETTING_ID, + PLAN_REVIEW_REPLAN_CAP_SETTING_ID, type WorkflowSettingsResolverStore, type EffectiveSettingsResult, type EffectiveSettingsTaskRef, diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 46c39f2f11..fd7e9ac61f 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -327,6 +327,10 @@ export interface WorkflowSettingDefinition { type: WorkflowSettingType; default?: unknown; options?: WorkflowSettingOption[]; + /** Inclusive lower bound for a number setting. */ + minimum?: number; + /** Require number values to be whole integers. */ + integer?: boolean; description?: string; render?: WorkflowSettingRender; } diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index ae18a4186b..f59e56aafb 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -1170,6 +1170,26 @@ function validateFields(fields: WorkflowFieldDefinition[] | undefined): void { } } +/** Validate number-only setting constraints before values/defaults consume them. */ +function validateSettingNumericConstraints(setting: WorkflowSettingDefinition): void { + if (setting.minimum !== undefined) { + if (setting.type !== "number") { + throw new WorkflowIrError(`Workflow setting '${setting.id}' minimum is only allowed for number settings`); + } + if (typeof setting.minimum !== "number" || !Number.isFinite(setting.minimum)) { + throw new WorkflowIrError(`Workflow setting '${setting.id}' minimum must be a finite number`); + } + } + if (setting.integer !== undefined) { + if (setting.type !== "number") { + throw new WorkflowIrError(`Workflow setting '${setting.id}' integer is only allowed for number settings`); + } + if (typeof setting.integer !== "boolean") { + throw new WorkflowIrError(`Workflow setting '${setting.id}' integer must be a boolean`); + } + } +} + /** Validate that a setting's `default` conforms to its own type/options (U1). * Unlike `validateFields`, settings validate defaults because the engine's * effective-settings resolver (U3) consumes the default directly — a malformed @@ -1193,6 +1213,12 @@ function validateSettingDefault(setting: WorkflowSettingDefinition): void { `Workflow setting '${id}' default must be a finite number`, ); } + if (setting.integer === true && !Number.isInteger(value)) { + throw new WorkflowIrError(`Workflow setting '${id}' default must be an integer`); + } + if (setting.minimum !== undefined && value < setting.minimum) { + throw new WorkflowIrError(`Workflow setting '${id}' default must be at least ${setting.minimum}`); + } break; case "boolean": if (typeof value !== "boolean") { @@ -1299,6 +1325,7 @@ function validateSettings(settings: WorkflowSettingDefinition[] | undefined): vo ); } } + validateSettingNumericConstraints(setting); validateSettingDefault(setting); } } diff --git a/packages/core/src/workflow-settings-resolver.ts b/packages/core/src/workflow-settings-resolver.ts index dc84cdac02..dc323849a1 100644 --- a/packages/core/src/workflow-settings-resolver.ts +++ b/packages/core/src/workflow-settings-resolver.ts @@ -39,6 +39,7 @@ import { PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, type Planner export const PLAN_REVIEW_MAX_REVISIONS_SETTING_ID = "planReviewMaxRevisions"; export const CODE_REVIEW_MAX_REVISIONS_SETTING_ID = "codeReviewMaxRevisions"; +export const PLAN_REVIEW_REPLAN_CAP_SETTING_ID = "planReviewReplanCap"; export type OptionalReviewRevisionBudget = NonNullable; const REVIEW_REVISION_SETTING_BY_GROUP_ID: Record = { diff --git a/packages/core/src/workflow-settings.ts b/packages/core/src/workflow-settings.ts index e1d47e89c5..d0b9357ffd 100644 --- a/packages/core/src/workflow-settings.ts +++ b/packages/core/src/workflow-settings.ts @@ -146,6 +146,12 @@ function validateValue( `setting '${setting.id}' expects a finite number, got ${typeof value === "number" ? String(value) : typeof value}`, ); } + if (setting.integer === true && !Number.isInteger(value)) { + return reject("type-mismatch", `setting '${setting.id}' expects an integer, got ${value}`); + } + if (setting.minimum !== undefined && value < setting.minimum) { + return reject("type-mismatch", `setting '${setting.id}' must be at least ${setting.minimum}, got ${value}`); + } return { ok: true, value }; } case "boolean": { diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index 807dd5f69f..6504a4dd03 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -751,6 +751,25 @@ ListView uses the same active-review token as TaskCard for the FN-7831 Reviewing color: var(--in-review); box-shadow: inset 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--in-review) 35%, transparent); } + +/* +FNXC:PlanReviewReplan 2026-07-15-12:15: +FN-7985 marks a plan parked after its automatic review/revise budget runs out as a warning, +not an ordinary triage hold. This shared ListView modifier applies to both grouped cards and +rows while preserving their existing badge geometry. +*/ +.list-status-badge--review-budget-exhausted { + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: var(--color-warning); + box-shadow: inset 0 0 0 var(--btn-border-width) color-mix(in srgb, var(--color-warning) 45%, transparent); +} + +@media (max-width: 768px) { + .list-status-badge--review-budget-exhausted { + max-width: 100%; + text-align: center; + } +} .list-status-badge--done { background: var(--status-done-bg); color: var(--done); diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index ed1c06dc77..e2aee6e597 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -23,6 +23,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection"; import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgress"; import { getTaskStatusBadgeLabel } from "../utils/taskStatusBadgeLabel"; +import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval"; import { useConfirm } from "../hooks/useConfirm"; import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete"; import { WorkflowSwitcher } from "./WorkflowSwitcher"; @@ -82,6 +83,7 @@ FNXC:MergeQueue 2026-07-15-10:45: List status column used to print raw engine statuses (landing/reviewing). Share the board badge mapper so list and card never diverge. */ function getTaskStatusLabel(status: string, t: TFunction<"app">): string { + if (status === "awaiting-approval") return t("tasks.awaitingApproval", "Awaiting Approval"); return getTaskStatusBadgeLabel(status, t); } type SortDirection = "asc" | "desc"; @@ -2683,6 +2685,7 @@ export function ListView({ !isStuckState && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); const hasStatus = typeof visualStatus === "string" && visualStatus.trim().length > 0; + const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task); const planReviewRunning = isPlanReviewRunning(task); const hasDependencies = Boolean(task.dependencies && task.dependencies.length > 0); const taskProgress = getTaskProgress(task); @@ -2738,8 +2741,14 @@ export function ListView({ ) : isStuckState ? ( {t("listView.stuck", "Stuck")} ) : hasStatus ? ( - - {getTaskStatusLabel(visualStatus ?? "", t)} + + {isReviewBudgetExhausted + ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") + : getTaskStatusLabel(visualStatus ?? "", t)} ) : null} {planReviewRunning && ( @@ -2893,6 +2902,7 @@ export function ListView({ !isPaused && !isStuckState && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); + const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task); const planReviewRunning = isPlanReviewRunning(task); const isDragging = draggingTaskId === task.id; @@ -2959,11 +2969,15 @@ export function ListView({ ) : visualStatus ? ( - {getTaskStatusLabel(visualStatus ?? "", t)} + {isReviewBudgetExhausted + ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") + : getTaskStatusLabel(visualStatus ?? "", t)} ) : ( - diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 6a51240a39..767594b85c 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -435,6 +435,24 @@ The Plan Review "Reviewing" badge must read as an active review state without ad color: var(--triage); } +/* +FNXC:PlanReviewReplan 2026-07-15-12:15: +FN-7985 promotes exhausted automatic Plan Review revisions from an ordinary approval hold to a +warning badge, while generic and release-authorization holds retain the triage appearance. +*/ +.card-status-badge.awaiting-approval--plan-review-replan-cap { + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: var(--color-warning); + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); +} + +@media (max-width: 768px) { + .card-status-badge.awaiting-approval--plan-review-replan-cap { + max-width: 100%; + text-align: center; + } +} + .card-status-badge.awaiting-input { background: color-mix(in srgb, var(--color-warning) 14%, transparent); color: var(--color-warning); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index a26d415088..5e92e6f388 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -43,6 +43,7 @@ import { getUnifiedTaskProgress, isPlanReviewRunning } from "../utils/taskProgre import { getPrBadgeModifierClass } from "../utils/prBadgeClass"; import { getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; import { getTaskStatusBadgeLabel } from "../utils/taskStatusBadgeLabel"; +import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval"; import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback"; import type { ToastType } from "../hooks/useToast"; import { useConfirm } from "../hooks/useConfirm"; @@ -1331,8 +1332,7 @@ function TaskCardComponent({ converging — Approve keeps the current PROMPT.md; Reject regenerates. */ const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval"; - const isPlanReviewReplanCapApproval = - isAwaitingApproval && task.awaitingApprovalReason === "plan-review-replan-cap"; + const isPlanReviewReplanCapApproval = isReviewBudgetExhaustedApproval(task); const isAwaitingInput = task.status === "awaiting-user-input"; const isArchived = task.column === "archived"; const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && !isAwaitingInput && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); @@ -3069,7 +3069,7 @@ function TaskCardComponent({ {isStuck ? t("tasks.stuck", "Stuck") : isPlanReviewReplanCapApproval - ? t("tasks.awaitingApprovalPlanReviewReplanCap", "Plan Review Cap") + ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") : isAwaitingApproval ? t("tasks.awaitingApproval", "Awaiting Approval") : isAwaitingInput diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 720594aac9..c65d12a228 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -72,6 +72,7 @@ import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy"; import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry"; import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; +import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval"; import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution"; import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel } from "./TaskContextMenu"; import { FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT } from "./FloatingWindow"; @@ -2796,8 +2797,7 @@ export function TaskDetailContent({ without converging so the operator is not guessing why the task is parked. */ const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval"; - const isPlanReviewReplanCapApproval = - isAwaitingApproval && task.awaitingApprovalReason === "plan-review-replan-cap"; + const isPlanReviewReplanCapApproval = isReviewBudgetExhaustedApproval(task); const handleTogglePause = useCallback(async () => { try { diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 84d51e82ba..978bc43088 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -430,6 +430,36 @@ describe("ListView", () => { expect(screen.getByText("View")).toBeDefined(); }); + it("renders the exhausted review budget indicator only for the matching approval reason in desktop rows", () => { + const viewportSpy = mockDesktopViewport(); + renderListView({ + tasks: [ + createMockTask({ id: "FN-BUDGET", status: "awaiting-approval", awaitingApprovalReason: "plan-review-replan-cap" }), + createMockTask({ id: "FN-MANUAL", status: "awaiting-approval" }), + createMockTask({ id: "FN-LEGACY", status: "awaiting-approval", awaitingApprovalReason: "release-authorization" }), + ], + }); + + expect(screen.getByTestId("list-review-budget-exhausted-FN-BUDGET")).toHaveTextContent("Review budget exhausted"); + expect(screen.getAllByText("Awaiting Approval")).toHaveLength(2); + viewportSpy.mockRestore(); + }); + + it("renders the exhausted review budget indicator only for the matching approval reason in grouped cards", () => { + const viewportSpy = mockMobileViewport(); + renderListView({ + tasks: [ + createMockTask({ id: "FN-BUDGET", status: "awaiting-approval", awaitingApprovalReason: "plan-review-replan-cap" }), + createMockTask({ id: "FN-MANUAL", status: "awaiting-approval" }), + createMockTask({ id: "FN-LEGACY", status: "awaiting-approval", awaitingApprovalReason: "release-authorization" }), + ], + }); + + expect(screen.getByTestId("list-review-budget-exhausted-FN-BUDGET")).toHaveTextContent("Review budget exhausted"); + expect(screen.getAllByText("Awaiting Approval")).toHaveLength(2); + viewportSpy.mockRestore(); + }); + it("falls back malformed task columns to Planning group instead of crashing", () => { const malformedTask = { ...createMockTask({ id: "FN-404" }), diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 142aa272d2..50813edcef 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -2270,7 +2270,20 @@ describe("TaskCard", () => { * When Plan Review exhausts automatic REVISE replans, the card must not look like a * generic require-all hold — badge text + title explain the non-convergence reason. */ - it("renders a distinct Plan Review Cap badge when awaitingApprovalReason is plan-review-replan-cap", () => { + it("keeps the generic Awaiting Approval badge for an ordinary manual hold", () => { + const { container } = render( + , + ); + + expect(within(container).getByText("Awaiting Approval")).toBeDefined(); + expect(container.querySelector(".awaiting-approval--plan-review-replan-cap")).toBeNull(); + }); + + it("renders a distinct review-budget-exhausted badge when awaitingApprovalReason is plan-review-replan-cap", () => { const { container } = render( { addToast={noop} />, ); - expect(within(container).getByText("Plan Review Cap")).toBeDefined(); + expect(within(container).getByText("Review budget exhausted")).toBeDefined(); expect(within(container).queryByText("Awaiting Approval")).toBeNull(); const badge = container.querySelector(".card-status-badge") as HTMLElement; expect(badge.className).toContain("awaiting-approval--plan-review-replan-cap"); diff --git a/packages/dashboard/app/components/workflow-setting-display.ts b/packages/dashboard/app/components/workflow-setting-display.ts index 296352ef85..6ad76451ee 100644 --- a/packages/dashboard/app/components/workflow-setting-display.ts +++ b/packages/dashboard/app/components/workflow-setting-display.ts @@ -101,6 +101,17 @@ const DISPLAY: Record = { */ description: "Leave empty for unbounded automatic Code Review remediation; set 0 to disable automatic revision.", }, + planReviewReplanCap: { + group: "review", + label: "Plan Review replan cap", + /* + * FNXC:WorkflowRevisionBudget 2026-07-15-12:00: + * FN-7985 exposes triage's separate consecutive Plan Review REVISE ceiling here. An + * empty value intentionally delegates to the engine constant rather than duplicating + * its default in the editable workflow setting. + */ + description: "Leave empty to use the built-in Plan Review replan default; set 0 to require approval after the first REVISE.", + }, triageProactiveSubtaskSplittingEnabled: { group: "steps", label: "Automatic large-task splitting", diff --git a/packages/dashboard/app/utils/reviewBudgetApproval.ts b/packages/dashboard/app/utils/reviewBudgetApproval.ts new file mode 100644 index 0000000000..1eb914ffbe --- /dev/null +++ b/packages/dashboard/app/utils/reviewBudgetApproval.ts @@ -0,0 +1,11 @@ +import type { Task } from "../../../core/src/types"; + +/** + * FNXC:PlanReviewReplan 2026-07-15-12:15: + * FN-7985 requires every task surface to distinguish the manual approval caused by the + * exhausted triage Plan Review budget from ordinary plan-approval and release-authorization + * holds. Keep the persisted reason check centralized so card, list, and detail stay aligned. + */ +export function isReviewBudgetExhaustedApproval(task: Task): boolean { + return task.status === "awaiting-approval" && task.awaitingApprovalReason === "plan-review-replan-cap"; +} diff --git a/packages/engine/src/__tests__/triage-plan-review-replan-cap.test.ts b/packages/engine/src/__tests__/triage-plan-review-replan-cap.test.ts index baa850c987..81da19ea6a 100644 --- a/packages/engine/src/__tests__/triage-plan-review-replan-cap.test.ts +++ b/packages/engine/src/__tests__/triage-plan-review-replan-cap.test.ts @@ -59,7 +59,11 @@ function createRetryTask(overrides: Partial = {}): Task { } as Task; } -function createStore(task: Task, settingsOverrides: Partial = {}): TaskStore { +function createStore( + task: Task, + settingsOverrides: Partial = {}, + workflowSettings: Record = {}, +): TaskStore { return { getTask: vi.fn().mockResolvedValue(task), listTasks: vi.fn().mockResolvedValue([task]), @@ -87,7 +91,7 @@ function createStore(task: Task, settingsOverrides: Partial = {}): Tas addSteeringComment: vi.fn(), getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }), getWorkflowDefinition: vi.fn().mockResolvedValue(undefined), - getWorkflowSettingValues: vi.fn().mockReturnValue({}), + getWorkflowSettingValues: vi.fn().mockReturnValue(workflowSettings), getWorkflowSettingsProjectId: vi.fn().mockReturnValue("project-plan-review-replan-cap"), on: vi.fn(), emit: vi.fn(), @@ -139,11 +143,36 @@ describe("Plan Review replan cap", () => { })); }); - it("escalates to awaiting-approval instead of replanning once the cap is reached", async () => { + it("uses a workflow-configured replan cap before escalating to manual approval", async () => { const rootDir = await createFixtureRoot(); roots.push(rootDir); - // Cap is 8: a task that has already consumed that many consecutive REVISE replans must - // escalate on the next REVISE rather than replanning again. + const task = createRetryTask({ + id: "FN-REPLAN-CAP-CONFIGURED", + planReviewReplanCount: 1, + }); + const prompt = `# Task: ${task.id} - Existing draft\n\n## Mission\n\nOnly rewrite after reviewer feedback.\n`; + await writePrompt(rootDir, task.id, prompt); + const store = createStore(task, {}, { planReviewReplanCap: 1 }); + mockReviewStep.mockResolvedValue({ verdict: "REVISE", review: "One configured attempt was enough.", summary: "Needs revision." }); + + await runGate(rootDir, task, store); + + expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ + status: "awaiting-approval", + awaitingApprovalReason: "plan-review-replan-cap", + })); + expect(store.logEntry).toHaveBeenCalledWith( + task.id, + "Plan Review replan cap reached — escalating to manual approval", + expect.stringContaining("cap 1"), + ); + }); + + it("falls back to the source cap when the workflow setting is unset", async () => { + const rootDir = await createFixtureRoot(); + roots.push(rootDir); + // The workflow-value map intentionally omits planReviewReplanCap. A task that has + // consumed the source-default cap must still escalate on the next REVISE. const task = createRetryTask({ id: "FN-REPLAN-CAP-HIT", planReviewReplanCount: 8, diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 02ced2b0f5..20aa903b10 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -2241,14 +2241,23 @@ export class TriageProcessor { `plan-review-replan-cap` so TaskCard/TaskDetailModal/notifications can tell the operator why approval is required (Plan Review did not converge) instead of looking like a generic require-all plan gate. + + FNXC:PlanReviewReplan 2026-07-15-12:00: + FN-7985 makes this triage-only ceiling a workflow setting. The source constant remains the + fallback when no valid non-negative finite integer is resolved, so an unset workflow follows + the coordinated built-in default instead of persisting a duplicate numeric default. */ - private async blockAfterPlanReviewRevise(task: Task, latestFeedback: string): Promise { + private async blockAfterPlanReviewRevise(task: Task, latestFeedback: string, settings: Settings): Promise { + const configuredCap = settings.planReviewReplanCap; + const replanCap = typeof configuredCap === "number" && Number.isFinite(configuredCap) && Number.isInteger(configuredCap) && configuredCap >= 0 + ? configuredCap + : PLAN_REVIEW_GATE_REPLAN_CAP; const priorCount = task.planReviewReplanCount ?? 0; - if (priorCount >= PLAN_REVIEW_GATE_REPLAN_CAP) { + if (priorCount >= replanCap) { await this.store.logEntry( task.id, PLAN_REVIEW_REPLAN_CAP_LOG_ACTION, - `The triage Plan Review gate requested a planning revision ${priorCount} consecutive times without converging (cap ${PLAN_REVIEW_GATE_REPLAN_CAP}). To avoid an endless plan → Plan Review REVISE → replan loop, the task is being routed to awaiting-approval for a human decision instead of replanning again. Latest Plan Review feedback:\n${latestFeedback}`, + `The triage Plan Review gate requested a planning revision ${priorCount} consecutive times without converging (cap ${replanCap}). To avoid an endless plan → Plan Review REVISE → replan loop, the task is being routed to awaiting-approval for a human decision instead of replanning again. Latest Plan Review feedback:\n${latestFeedback}`, ); /* FNXC:PlanReviewReplan 2026-07-13-00:00: @@ -2270,7 +2279,7 @@ export class TriageProcessor { }; await this.store.updateTask(task.id, escalationUpdates); planLog.warn( - `${task.id} Plan Review replan cap (${PLAN_REVIEW_GATE_REPLAN_CAP}) reached after ${priorCount} REVISE replans — escalating to awaiting-approval instead of replanning`, + `${task.id} Plan Review replan cap (${replanCap}) reached after ${priorCount} REVISE replans — escalating to awaiting-approval instead of replanning`, ); return; } @@ -2327,7 +2336,7 @@ export class TriageProcessor { "AI spec revision requested", `Plan Review deterministic external-integration evidence check requested a planning revision before execution.\n\nFeedback:\n${diagnostic}`, ); - await this.blockAfterPlanReviewRevise(task, diagnostic); + await this.blockAfterPlanReviewRevise(task, diagnostic, settings); return "blocked"; } } @@ -2461,7 +2470,7 @@ export class TriageProcessor { "AI spec revision requested", `Plan Review requested a planning revision before execution.\n\nStatus: ${review.verdict}\nFeedback:\n${reviseFeedback}`, ); - await this.blockAfterPlanReviewRevise(task, reviseFeedback); + await this.blockAfterPlanReviewRevise(task, reviseFeedback, settings); return "blocked"; } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 12d2d128f7..438a7d418c 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -8171,8 +8171,8 @@ "attachedFile": "Attached {{fileName}} to {{taskId}}", "attachFileFailed": "Failed to attach {{fileName}}: {{error}}", "awaitingApproval": "Awaiting Approval", - "awaitingApprovalPlanReviewReplanCap": "Plan Review Cap", "awaitingApprovalPlanReviewReplanCapTitle": "Plan Review requested revisions repeatedly without converging. Approve the current plan to proceed, or reject to regenerate it.", + "reviewBudgetExhausted": "Review budget exhausted", "awaitingApprovalTitle": "This plan needs your approval before implementation can start.", "baseBranch": "Base", "blockedByTooltip": "Blocked by {{taskId}} (file overlap)", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index c427e68973..f330dbec60 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -5697,7 +5697,7 @@ export default interface Resources { "openTasksInRightSidebarHelp": "When enabled, board task cards open detail in the right sidebar when it is available; mobile and hidden-sidebar states keep the full task panel. Default: disabled.", "showCostBadgeOnCards": "Show cost badges on task cards", "showCostBadgeOnCardsHelp": "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge.", - "suppressTheLdquoNeedsYourInputRdquoBanner": " Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. ", + "suppressTheLdquoNeedsYourInputRdquoBanner": "Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed.", "taskDetailChatFirstHelp": "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way.", "taskPopupsBoardListOnly": "Keep task popups on the view where they were opened", "taskPopupsBoardListOnlyHelp": "When enabled, each open task-detail popup appears only on the view where it was opened. Switching views hides it without closing; returning restores it in the same position. Default: enabled.", @@ -5856,6 +5856,8 @@ export default interface Resources { "allowEphemeralAgentsToCreateTasksHint": "When enabled (default), ephemeral task-worker agents can open follow-up tasks via fn_task_create. When disabled, only humans and permanent agents can create tasks; ephemeral callers are rejected.", "autoCleanupOldChats": "Auto-cleanup old chats", "autoPruneOldMail": "Auto-prune old mail", + "autoTranslateImportedIssues": "Auto-translate imported issues", + "autoTranslateImportedIssuesHelp": "When enabled, the Import Tasks panel automatically translates foreign-language issue titles and bodies into the target language below and shows the translation by default. You can always switch back to the original text, and imported tasks carry the translated text. Default: disabled.", "bannerFiresWhenTodoCountIsStrictlyGreater": "Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.", "builtInWorkflows": "Built-in workflows", "capacityRiskBanner": "Capacity Risk Banner", @@ -5872,6 +5874,7 @@ export default interface Resources { "disabledFusionWorkflowsAreHiddenFromWorkflow": "Disabled Fusion workflows are hidden from workflow pickers. Existing tasks that already use one continue to resolve. Default: all built-in workflows enabled (unset).", "enableSummarizationInProjectModelsToConfigureThatModel": " Enable summarization in Project Models to configure that model.", "fN": "FN", + "followDashboardLanguage": "Follow dashboard language", "general": "General", "gitHubTracking": "GitHub Tracking", "gitLabEnabledHint": "Configure GitLab.com or self-managed GitLab URLs. Blank values inherit global fallbacks and then GitLab.com. No default — unset (unset behaves as enabled until explicitly disabled).", @@ -5904,6 +5907,8 @@ export default interface Resources { "taskPrefix": "Task Prefix", "todoThreshold": "Todo threshold", "trackingIssuesUseThisTaskAposSTitle": " Tracking issues use this task's title. If a task has no title yet, Fusion can summarize its description using the title summarization model in Project Models. ", + "translationTargetLanguage": "Translation target language", + "translationTargetLanguageHelp": "Language imported issues are translated into when auto-translation is enabled. No default — unset inherits the dashboard language.", "upToDate": "You're up to date ✓", "updateAvailablePrefix": "v{{version}} available", "updateCheckFailed": "Failed to check for updates", @@ -6285,8 +6290,8 @@ export default interface Resources { "global": "Global setting", "project": "Project setting" }, - "globalMcp": "MCP Servers", - "mcp": "MCP Servers", + "globalMcp": "MCP Servers · Global", + "mcp": "MCP Servers · Project", "prompts": "Prompts", "secrets": "Secrets", "tooltip": { @@ -6687,11 +6692,15 @@ export default interface Resources { "allSections": "Showing all settings sections", "clear": "Clear settings search", "label": "Search settings", + "moreResults": "{{count}} more — keep typing to narrow", "navigationLabel": "Settings navigation", "noMobileOptions": "No sections match this search.", "noResults": "No settings sections match \"{{query}}\".", "placeholder": "Search by setting or section", - "resultCount": "{{count}} matching sections" + "resultCount_one": "{{count}} matching section", + "resultCount_other": "{{count}} matching sections", + "settingResultCount_one": "{{count}} matching setting", + "settingResultCount_other": "{{count}} matching settings" }, "title": "Settings", "worktrees": { @@ -8207,7 +8216,6 @@ export default interface Resources { "attachFileFailed": "Failed to attach {{fileName}}: {{error}}", "attachedFile": "Attached {{fileName}} to {{taskId}}", "awaitingApproval": "Awaiting Approval", - "awaitingApprovalPlanReviewReplanCap": "Plan Review Cap", "awaitingApprovalPlanReviewReplanCapTitle": "Plan Review requested revisions repeatedly without converging. Approve the current plan to proceed, or reject to regenerate it.", "awaitingApprovalTitle": "This plan needs your approval before implementation can start.", "baseBranch": "Base", @@ -8373,6 +8381,7 @@ export default interface Resources { "revertNeedsHumanDefault": "human review required", "revertTask": "Revert this task's changes", "reverted": "Reverted {{taskId}} in commit {{sha}}", + "reviewBudgetExhausted": "Review budget exhausted", "reviewerModel": "Reviewer Model", "save": "Save", "saving": "Saving...",