fix(engine): break Plan Review REVISE replan loop (feedback + bounded cap) (#2078)

## Problem
A task whose Plan Review step returns verdict `REVISE` can loop forever:
plan → plan-review REVISE → `needs-replan` → re-plan → near-identical
plan → REVISE → repeat. The triage **pre-execution** Plan Review gate
(`runPlanReviewBeforeExecution`) sets `status: "needs-replan"` on REVISE
with **no cap and no escape to `awaiting-approval`** — unlike the
executor graph path, which already has `PLAN_REVIEW_REPLAN_HARD_CAP`.
Under `planApprovalMode: require-all` there is also no human exit,
because the task never reaches `awaiting-approval`.

Separately, replan feedback (`triage.ts`) was derived only from
`task.log` comment actions + the latest user comment; it never consulted
the plan-review verdict stored in `task.workflowStepResults`.

## Fix
1. **Thread plan-review feedback into replan** — when re-planning with
no comment-derived feedback, seed `buildSpecificationPrompt` from the
most recent `plan-review` REVISE `output` in `workflowStepResults`
(existing user/AI-comment precedence preserved).
2. **Bounded cap** — new `planReviewReplanCount` counter (`types.ts`,
`store.ts` column + updateTask, `db.ts` migration 146,
`manual-retry-reset.ts`). After `PLAN_REVIEW_GATE_REPLAN_CAP = 3`
consecutive REVISE replans the task escalates to `awaiting-approval`
(`awaitingApprovalReason: "plan-review-replan-cap"`) instead of
replanning. Counter resets on APPROVE.

## Tests
Adds `triage-replan-feedback-from-plan-review.test.ts` and
`triage-plan-review-replan-cap.test.ts`. Merge gate green locally
(`verify:fast`, `test:gate` 337+63, `lint`); changeset included.

Made with Claude (see `Co-Authored-By` trailer).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Prevented Plan Review “REVISE” from looping indefinitely by enforcing
a bounded replan cap.
* After repeated Plan Review replans, tasks now escalate to an
approval-hold state with a dedicated reason.
* Improved replan feedback by seeding from the latest Plan Review output
when no explicit feedback is available; the counter clears when Plan
Review approves.
  * Manual retries now reset the Plan Review replan cap counter.
* **Documentation**
  * Added release notes describing the Plan Review replan safeguards.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Victor Canô
2026-07-14 12:15:09 -03:00
committed by GitHub
parent 03966ecb79
commit bc348345a4
16 changed files with 549 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Plan Review revisions no longer loop forever; tasks escalate to approval after repeated revises.
category: fix
dev: The triage pre-execution plan-review gate now seeds replan feedback from the plan-review REVISE output in workflowStepResults and caps consecutive REVISE replans at 3 (new planReviewReplanCount counter — a plan_review_replan_count integer column on the PostgreSQL tasks table, self-healing on existing embedded-PG databases via postgres-health), routing the task to awaiting-approval instead of looping.

View File

@@ -13,6 +13,7 @@ export const MANUAL_RETRY_RESET_COUNTER_KEYS = [
"workflowStepRetries", "workflowStepRetries",
"verificationFailureCount", "verificationFailureCount",
"postReviewFixCount", "postReviewFixCount",
"planReviewReplanCount",
"mergeConflictBounceCount", "mergeConflictBounceCount",
"branchConflictRecoveryCount", "branchConflictRecoveryCount",
"reviewerContextRetryCount", "reviewerContextRetryCount",

View File

@@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS project.tasks (
-- FNXC:SqliteFinalRemoval 2026-06-25: retry/stuck counters missed in initial snapshot -- FNXC:SqliteFinalRemoval 2026-06-25: retry/stuck counters missed in initial snapshot
stuck_kill_count integer DEFAULT 0, stuck_kill_count integer DEFAULT 0,
post_review_fix_count integer DEFAULT 0, post_review_fix_count integer DEFAULT 0,
plan_review_replan_count integer DEFAULT 0,
verification_failure_count integer DEFAULT 0, verification_failure_count integer DEFAULT 0,
branch_conflict_recovery_count integer DEFAULT 0, branch_conflict_recovery_count integer DEFAULT 0,
reviewer_context_retry_count integer DEFAULT 0, reviewer_context_retry_count integer DEFAULT 0,

View File

@@ -167,6 +167,10 @@ export const EXPECTED_PROJECT_COLUMNS: ReadonlyArray<{ schema?: string; table: s
// FNXC:WorkflowLifecycle 2026-07-12: FN-7863 execute self-requeue streak (merge port). // FNXC:WorkflowLifecycle 2026-07-12: FN-7863 execute self-requeue streak (merge port).
{ table: "tasks", column: "execute_requeue_loop_count", type: "integer" }, { table: "tasks", column: "execute_requeue_loop_count", type: "integer" },
{ table: "tasks", column: "execute_requeue_loop_signature", type: "text" }, { table: "tasks", column: "execute_requeue_loop_signature", type: "text" },
// FNXC:PlanReviewReplan 2026-07-13: bounded triage Plan Review REVISE replan counter.
// Additive column not present in the baseline snapshot, so existing embedded-PG
// databases must self-heal it via ALTER TABLE ADD COLUMN IF NOT EXISTS on boot.
{ table: "tasks", column: "plan_review_replan_count", type: "integer" },
// distributed_task_id_state // distributed_task_id_state
{ table: "distributed_task_id_state", column: "prefix", type: "text" }, { table: "distributed_task_id_state", column: "prefix", type: "text" },
{ table: "distributed_task_id_state", column: "next_sequence", type: "integer" }, { table: "distributed_task_id_state", column: "next_sequence", type: "integer" },

View File

@@ -122,6 +122,7 @@ export const tasks = projectSchema.table("tasks", {
*/ */
stuckKillCount: integer("stuck_kill_count").default(0), stuckKillCount: integer("stuck_kill_count").default(0),
postReviewFixCount: integer("post_review_fix_count").default(0), postReviewFixCount: integer("post_review_fix_count").default(0),
planReviewReplanCount: integer("plan_review_replan_count").default(0),
verificationFailureCount: integer("verification_failure_count").default(0), verificationFailureCount: integer("verification_failure_count").default(0),
branchConflictRecoveryCount: integer("branch_conflict_recovery_count").default(0), branchConflictRecoveryCount: integer("branch_conflict_recovery_count").default(0),
reviewerContextRetryCount: integer("reviewer_context_retry_count").default(0), reviewerContextRetryCount: integer("reviewer_context_retry_count").default(0),

View File

@@ -1138,7 +1138,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} }
async updateTask( async updateTask(
id: string, id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; approvedPlanFingerprint?: string | null }, updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; approvedPlanFingerprint?: string | null },
runContext?: RunMutationContext, runContext?: RunMutationContext,
): Promise<Task> { ): Promise<Task> {
return updateTaskImpl(this, id, updates, runContext); return updateTaskImpl(this, id, updates, runContext);

View File

@@ -52,6 +52,7 @@ export interface TaskRow {
executeRequeueLoopCount: number | null; executeRequeueLoopCount: number | null;
executeRequeueLoopSignature: string | null; executeRequeueLoopSignature: string | null;
postReviewFixCount: number | null; postReviewFixCount: number | null;
planReviewReplanCount: number | null;
recoveryRetryCount: number | null; recoveryRetryCount: number | null;
taskDoneRetryCount: number | null; taskDoneRetryCount: number | null;
worktreeSessionRetryCount: number | null; worktreeSessionRetryCount: number | null;
@@ -210,6 +211,7 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [
defineTaskColumn("executeRequeueLoopCount", (task) => task.executeRequeueLoopCount ?? 0), defineTaskColumn("executeRequeueLoopCount", (task) => task.executeRequeueLoopCount ?? 0),
defineTaskColumn("executeRequeueLoopSignature", (task) => task.executeRequeueLoopSignature ?? null), defineTaskColumn("executeRequeueLoopSignature", (task) => task.executeRequeueLoopSignature ?? null),
defineTaskColumn("postReviewFixCount", (task) => task.postReviewFixCount ?? 0), defineTaskColumn("postReviewFixCount", (task) => task.postReviewFixCount ?? 0),
defineTaskColumn("planReviewReplanCount", (task) => task.planReviewReplanCount ?? 0),
defineTaskColumn("recoveryRetryCount", (task) => task.recoveryRetryCount ?? null), defineTaskColumn("recoveryRetryCount", (task) => task.recoveryRetryCount ?? null),
defineTaskColumn("taskDoneRetryCount", (task) => task.taskDoneRetryCount ?? 0), defineTaskColumn("taskDoneRetryCount", (task) => task.taskDoneRetryCount ?? 0),
defineTaskColumn("worktreeSessionRetryCount", (task) => task.worktreeSessionRetryCount ?? 0), defineTaskColumn("worktreeSessionRetryCount", (task) => task.worktreeSessionRetryCount ?? 0),

View File

@@ -43,7 +43,7 @@ export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, li
"modelPresetId", "modelProvider", "modelId", "modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId", "validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId", "planningModelProvider", "planningModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "executionMode", "error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",

View File

@@ -34,7 +34,7 @@ export function getTaskSelectClauseImpl2(store: TaskStore, slim: boolean, tableA
"modelPresetId", "modelProvider", "modelId", "modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId", "validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId", "planningModelProvider", "planningModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "executionMode", "error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",

View File

@@ -624,7 +624,7 @@ export async function resetPromptCheckboxesImpl(store: TaskStore, dir: string):
export async function updateTaskImpl(store: TaskStore, export async function updateTaskImpl(store: TaskStore,
id: string, id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined }, updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined },
runContext?: RunMutationContext, runContext?: RunMutationContext,
): Promise<Task> { ): Promise<Task> {
/* /*

View File

@@ -106,6 +106,7 @@ export function rowToTask(row: TaskRow): Task {
executeRequeueLoopCount: row.executeRequeueLoopCount ?? undefined, executeRequeueLoopCount: row.executeRequeueLoopCount ?? undefined,
executeRequeueLoopSignature: row.executeRequeueLoopSignature || undefined, executeRequeueLoopSignature: row.executeRequeueLoopSignature || undefined,
postReviewFixCount: row.postReviewFixCount ?? undefined, postReviewFixCount: row.postReviewFixCount ?? undefined,
planReviewReplanCount: row.planReviewReplanCount ?? undefined,
recoveryRetryCount: row.recoveryRetryCount ?? undefined, recoveryRetryCount: row.recoveryRetryCount ?? undefined,
taskDoneRetryCount: row.taskDoneRetryCount ?? undefined, taskDoneRetryCount: row.taskDoneRetryCount ?? undefined,
worktreeSessionRetryCount: row.worktreeSessionRetryCount ?? undefined, worktreeSessionRetryCount: row.worktreeSessionRetryCount ?? undefined,

View File

@@ -389,6 +389,11 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
} else if (updates.postReviewFixCount !== undefined) { } else if (updates.postReviewFixCount !== undefined) {
task.postReviewFixCount = updates.postReviewFixCount; task.postReviewFixCount = updates.postReviewFixCount;
} }
if (updates.planReviewReplanCount === null) {
task.planReviewReplanCount = undefined;
} else if (updates.planReviewReplanCount !== undefined) {
task.planReviewReplanCount = updates.planReviewReplanCount;
}
if (updates.recoveryRetryCount === null) { if (updates.recoveryRetryCount === null) {
task.recoveryRetryCount = undefined; task.recoveryRetryCount = undefined;
} else if (updates.recoveryRetryCount !== undefined) { } else if (updates.recoveryRetryCount !== undefined) {

View File

@@ -2481,6 +2481,16 @@ export interface Task {
* Review defaults to unbounded recovery so ordinary REVISE feedback does not * Review defaults to unbounded recovery so ordinary REVISE feedback does not
* terminal-fail the task. */ * terminal-fail the task. */
postReviewFixCount?: number; postReviewFixCount?: number;
/** Number of consecutive triage pre-execution Plan Review REVISE replans this task
* has consumed. Incremented by the triage Plan Review gate
* (packages/engine/src/triage.ts runPlanReviewBeforeExecution) each time it blocks
* execution with a REVISE verdict and routes the task back to `needs-replan`. When it
* reaches `PLAN_REVIEW_GATE_REPLAN_CAP` the task is escalated to `awaiting-approval`
* (awaitingApprovalReason `plan-review-replan-cap`) instead of replanning again, so a
* planner/reviewer disagreement can never loop forever. Reset when the gate passes
* (APPROVE) or on a manual retry. Distinct from `postReviewFixCount`, which bounds the
* executor graph's post-merge/advisory optional-step REVISE budget. */
planReviewReplanCount?: number;
/** Number of bounded recovery retry attempts for transient executor/triage failures. /** Number of bounded recovery retry attempts for transient executor/triage failures.
* Distinct from `mergeRetries` (merge-conflict-specific). Incremented by the * Distinct from `mergeRetries` (merge-conflict-specific). Incremented by the
* recovery-policy module on each recoverable failure; cleared when work restarts * recovery-policy module on each recoverable failure; cleared when work restarts
@@ -2560,7 +2570,7 @@ export interface Task {
* any such hold as an ordinary manual plan-approval hold (Approve/Reject Plan render * any such hold as an ordinary manual plan-approval hold (Approve/Reject Plan render
* normally). Undefined means either no hold or a manual-approval hold. * normally). Undefined means either no hold or a manual-approval hold.
*/ */
awaitingApprovalReason?: "release-authorization"; awaitingApprovalReason?: "release-authorization" | "plan-review-replan-cap";
/* /*
* FNXC:PlanApproval 2026-07-04-22:41: * FNXC:PlanApproval 2026-07-04-22:41:
* FN-7569 — records the computePlanApprovalFingerprint (packages/core/src/plan-approval.ts) * FN-7569 — records the computePlanApprovalFingerprint (packages/core/src/plan-approval.ts)

View File

@@ -0,0 +1,186 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { join } from "node:path";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { TriageProcessor } from "../triage.js";
/*
* Bug A (part 2): the triage pre-execution Plan Review gate must bound consecutive
* REVISE replans so a persistent planner/reviewer disagreement escalates to
* awaiting-approval instead of looping plan -> Plan Review REVISE -> replan forever.
*/
const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({
mockReviewStep: vi.fn(),
mockCreateFnAgent: vi.fn(),
}));
vi.mock("../reviewer.js", () => ({
reviewStep: mockReviewStep,
}));
vi.mock("../pi.js", () => ({
createFnAgent: mockCreateFnAgent,
describeModel: vi.fn().mockReturnValue("mock-model"),
promptWithFallback: vi.fn().mockReturnValue("mock-prompt"),
}));
vi.mock("@fusion/core", async (importOriginal) => {
const { createEngineCoreMock } = await import("../test/mockCore.js");
const original = await importOriginal<typeof import("@fusion/core")>();
return createEngineCoreMock(() => Promise.resolve(original));
});
async function createFixtureRoot(): Promise<string> {
return mkdtemp(join(tmpdir(), "fusion-triage-plan-review-replan-cap-"));
}
async function cleanupFixtureRoot(rootDir: string): Promise<void> {
await rm(rootDir, { recursive: true, force: true });
}
function createRetryTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-REPLAN-CAP",
description: "Bounded Plan Review replan",
title: "Bounded Plan Review replan",
column: "triage",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
enabledWorkflowSteps: ["plan-review", "code-review"],
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
} as Task;
}
function createStore(task: Task, settingsOverrides: Partial<Settings> = {}): TaskStore {
return {
getTask: vi.fn().mockResolvedValue(task),
listTasks: vi.fn().mockResolvedValue([task]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: false,
...settingsOverrides,
} as Settings),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
updateSettings: vi.fn(),
addSteeringComment: vi.fn(),
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
getWorkflowSettingValues: vi.fn().mockReturnValue({}),
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("project-plan-review-replan-cap"),
on: vi.fn(),
emit: vi.fn(),
} as unknown as TaskStore;
}
async function writePrompt(rootDir: string, taskId: string, prompt: string): Promise<string> {
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
const promptPath = join(taskDir, "PROMPT.md");
await writeFile(promptPath, prompt, "utf-8");
return promptPath;
}
async function runGate(rootDir: string, task: Task, store = createStore(task)): Promise<TaskStore> {
const processor = new TriageProcessor(store, rootDir);
await processor.specifyTask(task);
return store;
}
describe("Plan Review replan cap", () => {
let roots: string[] = [];
afterEach(async () => {
mockReviewStep.mockReset();
mockCreateFnAgent.mockReset();
await Promise.all(roots.map(cleanupFixtureRoot));
roots = [];
});
it("increments the replan counter and stays in needs-replan below the cap", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const task = createRetryTask({ id: "FN-REPLAN-CAP-BELOW", 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);
mockReviewStep.mockResolvedValue({ verdict: "REVISE", review: "Please tighten the file scope.", summary: "Needs revision." });
await runGate(rootDir, task, store);
// Still replans, but bumps the consecutive-REVISE counter toward the cap.
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: "needs-replan",
planReviewReplanCount: 2,
}));
expect(store.updateTask).not.toHaveBeenCalledWith(task.id, expect.objectContaining({
status: "awaiting-approval",
}));
});
it("escalates to awaiting-approval instead of replanning once the cap is reached", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
// Cap is 3: a task that has already consumed 3 consecutive REVISE replans must
// escalate on the next REVISE rather than replanning a 4th time.
const task = createRetryTask({ id: "FN-REPLAN-CAP-HIT", planReviewReplanCount: 3 });
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);
const feedback = "Reviewer keeps rejecting the same plan.";
mockReviewStep.mockResolvedValue({ verdict: "REVISE", review: feedback, 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.updateTask).not.toHaveBeenCalledWith(task.id, expect.objectContaining({
status: "needs-replan",
}));
expect(store.logEntry).toHaveBeenCalledWith(
task.id,
"Plan Review replan cap reached — escalating to manual approval",
expect.stringContaining(feedback),
);
});
it("resets the replan counter when Plan Review passes", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const task = createRetryTask({ id: "FN-REPLAN-CAP-RESET", planReviewReplanCount: 2 });
const prompt = `# Task: ${task.id} - Existing draft\n\n## Mission\n\nKeep this exact text.\n`;
await writePrompt(rootDir, task.id, prompt);
const store = createStore(task);
mockReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Approved.", summary: "Ready." });
await runGate(rootDir, task, store);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
planReviewReplanCount: null,
}));
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
});
});

View File

@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Settings, Task, TaskDetail, TaskStore } from "@fusion/core";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TriageProcessor } from "../triage.js";
/*
* Bug A (part 1): when re-planning and no explicit user/AI-comment feedback exists,
* the planner prompt must be seeded from the most recent Plan Review REVISE output
* stored in workflowStepResults — otherwise the planner re-plans with
* `feedback: undefined` and regenerates the same rejected plan.
*/
const { mockCreateResolvedAgentSession, mockPromptWithFallback } = vi.hoisted(() => ({
mockCreateResolvedAgentSession: vi.fn(),
mockPromptWithFallback: vi.fn(),
}));
vi.mock("../agent-session-helpers.js", () => ({
createResolvedAgentSession: mockCreateResolvedAgentSession,
extractRuntimeHint: vi.fn(),
resolvePlanningSessionModel: vi.fn().mockReturnValue({ provider: "mock", modelId: "mock-model" }),
resolveExecutorThinkingLevel: vi.fn(() => undefined),
resolveExecutorFallbackThinkingLevel: vi.fn(() => undefined),
resolvePlanningThinkingLevel: vi.fn(() => undefined),
resolvePlanningFallbackThinkingLevel: vi.fn(() => undefined),
resolveValidatorThinkingLevel: vi.fn(() => undefined),
resolveValidatorFallbackThinkingLevel: vi.fn(() => undefined),
resolveMergerThinkingLevel: vi.fn(() => undefined),
resolveMergerFallbackThinkingLevel: vi.fn(() => undefined),
resolveImplicitPlanningFallbackModel: vi.fn(() => ({ provider: undefined, modelId: undefined })),
}));
vi.mock("../pi.js", () => {
class ModelFallbackExhaustedError extends Error {}
return {
describeModel: vi.fn().mockReturnValue("mock-model"),
promptWithFallback: mockPromptWithFallback,
formatModelMarkerDetails: vi.fn((model: string) => model),
ModelFallbackExhaustedError,
};
});
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-REPLAN-FEEDBACK",
title: "Replan feedback source",
description: "Re-plan a task that only has Plan Review REVISE feedback",
column: "triage",
status: "needs-replan",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-07-13T00:00:00.000Z",
updatedAt: "2026-07-13T00:00:00.000Z",
...overrides,
} as Task;
}
function toDetail(task: Task): TaskDetail {
return {
...task,
attachments: [],
comments: [],
log: task.log ?? [],
} as TaskDetail;
}
function createMutableStore(initialTask: Task, settings: Partial<Settings> = {}) {
let currentTask: Task = { ...initialTask, log: [...(initialTask.log ?? [])] };
const store = {
getTask: vi.fn(async () => toDetail(currentTask)),
listTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({
pollIntervalMs: 60_000,
maxConcurrent: 1,
maxWorktrees: 1,
autoMerge: true,
groupOverlappingFiles: false,
maxStuckKills: 6,
requirePlanApproval: false,
...settings,
} as Settings),
getTaskDocument: vi.fn(async () => null),
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => {
currentTask = { ...currentTask, ...updates, updatedAt: "2026-07-13T00:01:00.000Z" } as Task;
return currentTask;
}),
moveTask: vi.fn(async (_id: string, column: Task["column"]) => {
currentTask = { ...currentTask, column, status: null } as Task;
return currentTask;
}),
logEntry: vi.fn(async (_id: string, action: string, outcome?: string) => {
currentTask = {
...currentTask,
log: [...(currentTask.log ?? []), { timestamp: new Date().toISOString(), action, outcome }],
} as Task;
}),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
return {
store,
get currentTask() {
return currentTask;
},
};
}
async function createRoot(taskId: string): Promise<string> {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-triage-replan-feedback-"));
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
return rootDir;
}
function mockSession() {
mockCreateResolvedAgentSession.mockResolvedValue({
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
});
}
async function cleanup(rootDir: string | undefined) {
if (rootDir) {
await rm(rootDir, { recursive: true, force: true });
}
}
describe("triage replan feedback falls back to Plan Review REVISE output", () => {
let rootDir: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
mockSession();
});
afterEach(async () => {
await cleanup(rootDir);
rootDir = undefined;
});
it("seeds the planner prompt from the latest plan-review REVISE output when no comment feedback exists", async () => {
const reviseOutput = "PLAN-REVIEW-REVISE-MARKER: the plan omits the required migration step and must add it.";
const task = createTask({
id: "FN-REPLAN-FEEDBACK-WSR",
// No user comments and no "AI spec revision requested" log entry — the only
// available feedback is the Plan Review REVISE result in workflowStepResults.
log: [],
workflowStepResults: [
{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
phase: "pre-merge",
status: "failed",
verdict: "REVISE",
output: reviseOutput,
notes: "Needs a migration step.",
},
],
});
rootDir = await createRoot(task.id);
const harness = createMutableStore(task);
const processor = new TriageProcessor(harness.store, rootDir);
let capturedPrompt: string | undefined;
mockPromptWithFallback.mockImplementationOnce(async (_session: unknown, agentPrompt: string) => {
capturedPrompt = agentPrompt;
// Short-circuit the rest of planning; we only assert the prompt was seeded.
processor.markStuckAborted(task.id);
});
await processor.specifyTask(harness.currentTask);
expect(mockPromptWithFallback).toHaveBeenCalled();
expect(capturedPrompt).toBeDefined();
expect(capturedPrompt).toContain(reviseOutput);
});
it("prefers an explicit AI spec revision comment over the workflowStepResults fallback", async () => {
const reviseOutput = "PLAN-REVIEW-REVISE-MARKER: stale fallback that must not win.";
const explicitFeedback = "EXPLICIT-COMMENT-FEEDBACK: address the auth edge case first.";
const task = createTask({
id: "FN-REPLAN-FEEDBACK-PRECEDENCE",
log: [
{
timestamp: "2026-07-13T00:00:30.000Z",
action: "AI spec revision requested",
outcome: explicitFeedback,
},
],
workflowStepResults: [
{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
phase: "pre-merge",
status: "failed",
verdict: "REVISE",
output: reviseOutput,
},
],
});
rootDir = await createRoot(task.id);
const harness = createMutableStore(task);
const processor = new TriageProcessor(harness.store, rootDir);
let capturedPrompt: string | undefined;
mockPromptWithFallback.mockImplementationOnce(async (_session: unknown, agentPrompt: string) => {
capturedPrompt = agentPrompt;
processor.markStuckAborted(task.id);
});
await processor.specifyTask(harness.currentTask);
expect(capturedPrompt).toBeDefined();
expect(capturedPrompt).toContain(explicitFeedback);
expect(capturedPrompt).not.toContain(reviseOutput);
});
});

View File

@@ -51,6 +51,20 @@ type TaskListFormatter = (
const TRIAGE_STUCK_RESUME_LOG_ACTION = "Triage stuck re-queue will resume existing planning draft"; const TRIAGE_STUCK_RESUME_LOG_ACTION = "Triage stuck re-queue will resume existing planning draft";
const TRIAGE_STUCK_RESUME_FEEDBACK = "The previous triage session was killed by the stuck-task detector after writing a non-empty planning draft. Resume from the existing draft below: preserve useful structure and decisions, fill gaps, and continue toward review instead of restarting planning from scratch."; const TRIAGE_STUCK_RESUME_FEEDBACK = "The previous triage session was killed by the stuck-task detector after writing a non-empty planning draft. Resume from the existing draft below: preserve useful structure and decisions, fill gaps, and continue toward review instead of restarting planning from scratch.";
/*
FNXC:PlanReviewReplan 2026-07-13-00:00:
The triage pre-execution Plan Review gate (runPlanReviewBeforeExecution) routes a REVISE
verdict back to `needs-replan`, which re-plans and re-reviews. Without a ceiling, a planner
and reviewer that persistently disagree loop plan → Plan Review REVISE → replan forever
(observed on TC-002), and in `planApprovalMode: require-all` there is no human escape because
the task never reaches `awaiting-approval`. Bound the consecutive REVISE replans with a small
cap (mirroring the executor graph's PLAN_REVIEW_REPLAN_HARD_CAP backstop): after this many
replans the gate escalates the task to `awaiting-approval` for a human decision instead of
replanning again. The counter (Task.planReviewReplanCount) resets when the gate passes.
*/
const PLAN_REVIEW_GATE_REPLAN_CAP = 3;
const PLAN_REVIEW_REPLAN_CAP_LOG_ACTION = "Plan Review replan cap reached — escalating to manual approval";
export function inlineTaskListFallback( export function inlineTaskListFallback(
lines: string[], lines: string[],
opts: { maxChars?: number } = {}, opts: { maxChars?: number } = {},
@@ -1258,6 +1272,28 @@ export class TriageProcessor {
feedback = latestUserComment?.text; feedback = latestUserComment?.text;
} }
/*
FNXC:PlanReviewReplan 2026-07-13-00:00:
When re-planning and neither an explicit user/AI re-specification comment nor a
user comment supplied feedback, fall back to the most recent Plan Review REVISE
verdict recorded in `workflowStepResults`. The pre-execution Plan Review gate
(runPlanReviewBeforeExecution) stores its rejection reasoning there authoritatively
(it is upserted every cycle and never evicted by the activity-log cap), so this
keeps the planner regenerating against the reviewer's actual objections instead of
reproducing the same rejected plan with `feedback: undefined` and looping. Explicit
comment-derived feedback still wins because this only runs when none was found.
*/
if (!feedback) {
const latestPlanReviewRevise = [...(currentTask.workflowStepResults || [])]
.reverse()
.find((result) =>
result.workflowStepId === PLAN_REVIEW_GROUP_ID
&& result.verdict === "REVISE"
&& Boolean((result.output ?? result.notes)?.trim()),
);
feedback = latestPlanReviewRevise?.output ?? latestPlanReviewRevise?.notes ?? feedback;
}
planLog.log( planLog.log(
`${task.id} re-planning with feedback: ${feedback?.slice(0, 100)}...`, `${task.id} re-planning with feedback: ${feedback?.slice(0, 100)}...`,
); );
@@ -2004,6 +2040,55 @@ export class TriageProcessor {
await this.store.updateTask(task.id, { workflowStepResults: existing }); await this.store.updateTask(task.id, { workflowStepResults: existing });
} }
/*
FNXC:PlanReviewReplan 2026-07-13-00:00:
Shared terminal step for a triage Plan Review gate REVISE. Increments the consecutive-replan
counter and routes the task back to `needs-replan` for another planning pass — until the count
reaches PLAN_REVIEW_GATE_REPLAN_CAP, after which it escalates to `awaiting-approval` (with a
clear log entry and a distinct awaitingApprovalReason) so a persistent planner/reviewer
disagreement surfaces to a human instead of looping forever. Callers still record the workflow
step result and the "AI spec revision requested" feedback log before invoking this.
*/
private async blockAfterPlanReviewRevise(task: Task, latestFeedback: string): Promise<void> {
const priorCount = task.planReviewReplanCount ?? 0;
if (priorCount >= PLAN_REVIEW_GATE_REPLAN_CAP) {
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}`,
);
/*
FNXC:PlanReviewReplan 2026-07-13-00:00:
`awaitingApprovalReason` is not a persisted `updateTask` column in the PostgreSQL
store (it survives only as a Task type field after the release-authorization gate
was removed), so the distinct reason is written through a Record<string, unknown>
the same way the manual plan-approval hold clears it below. The escalated task
renders as an ordinary manual plan-approval hold (only the legacy
"release-authorization" value is special-cased in the dashboard), which is exactly
the intended human Approve/Reject decision point.
*/
const escalationUpdates: Record<string, unknown> = {
status: "awaiting-approval",
awaitingApprovalReason: "plan-review-replan-cap",
error: null,
recoveryRetryCount: null,
nextRecoveryAt: null,
};
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`,
);
return;
}
await this.store.updateTask(task.id, {
status: "needs-replan",
planReviewReplanCount: priorCount + 1,
error: null,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
}
private async runPlanReviewBeforeExecution(task: Task, promptContent: string, settings: Settings): Promise<"approved" | "blocked"> { private async runPlanReviewBeforeExecution(task: Task, promptContent: string, settings: Settings): Promise<"approved" | "blocked"> {
if (!this.isPlanReviewEnabled(task)) { if (!this.isPlanReviewEnabled(task)) {
return "approved"; return "approved";
@@ -2048,12 +2133,7 @@ export class TriageProcessor {
"AI spec revision requested", "AI spec revision requested",
`Plan Review deterministic external-integration evidence check requested a planning revision before execution.\n\nFeedback:\n${diagnostic}`, `Plan Review deterministic external-integration evidence check requested a planning revision before execution.\n\nFeedback:\n${diagnostic}`,
); );
await this.store.updateTask(task.id, { await this.blockAfterPlanReviewRevise(task, diagnostic);
status: "needs-replan",
error: null,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
return "blocked"; return "blocked";
} }
} }
@@ -2117,6 +2197,11 @@ export class TriageProcessor {
startedAt, startedAt,
completedAt, completedAt,
}); });
// FNXC:PlanReviewReplan 2026-07-13-00:00: a passing gate clears the consecutive-REVISE
// replan counter so a later, unrelated revision cycle starts from a fresh budget.
if ((task.planReviewReplanCount ?? 0) > 0) {
await this.store.updateTask(task.id, { planReviewReplanCount: null });
}
await this.store.logEntry(task.id, "[pre-merge] Workflow step completed: Plan Review", review.summary); await this.store.logEntry(task.id, "[pre-merge] Workflow step completed: Plan Review", review.summary);
return "approved"; return "approved";
} }
@@ -2134,17 +2219,13 @@ export class TriageProcessor {
completedAt, completedAt,
}); });
await this.store.logEntry(task.id, "[pre-merge] Workflow step failed: Plan Review", review.review); await this.store.logEntry(task.id, "[pre-merge] Workflow step failed: Plan Review", review.review);
const reviseFeedback = review.review || review.summary || "(no feedback captured)";
await this.store.logEntry( await this.store.logEntry(
task.id, task.id,
"AI spec revision requested", "AI spec revision requested",
`Plan Review requested a planning revision before execution.\n\nStatus: ${review.verdict}\nFeedback:\n${review.review || review.summary || "(no feedback captured)"}`, `Plan Review requested a planning revision before execution.\n\nStatus: ${review.verdict}\nFeedback:\n${reviseFeedback}`,
); );
await this.store.updateTask(task.id, { await this.blockAfterPlanReviewRevise(task, reviseFeedback);
status: "needs-replan",
error: null,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
return "blocked"; return "blocked";
} }