diff --git a/.changeset/fix-plan-review-replan-loop.md b/.changeset/fix-plan-review-replan-loop.md new file mode 100644 index 0000000000..005a92c2fe --- /dev/null +++ b/.changeset/fix-plan-review-replan-loop.md @@ -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. diff --git a/packages/core/src/manual-retry-reset.ts b/packages/core/src/manual-retry-reset.ts index 97095d4a73..6229807569 100644 --- a/packages/core/src/manual-retry-reset.ts +++ b/packages/core/src/manual-retry-reset.ts @@ -13,6 +13,7 @@ export const MANUAL_RETRY_RESET_COUNTER_KEYS = [ "workflowStepRetries", "verificationFailureCount", "postReviewFixCount", + "planReviewReplanCount", "mergeConflictBounceCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index 3284434192..2b4c4e0c80 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS project.tasks ( -- FNXC:SqliteFinalRemoval 2026-06-25: retry/stuck counters missed in initial snapshot stuck_kill_count integer DEFAULT 0, post_review_fix_count integer DEFAULT 0, + plan_review_replan_count integer DEFAULT 0, verification_failure_count integer DEFAULT 0, branch_conflict_recovery_count integer DEFAULT 0, reviewer_context_retry_count integer DEFAULT 0, diff --git a/packages/core/src/postgres/postgres-health.ts b/packages/core/src/postgres/postgres-health.ts index b70e92dcbd..7a75c7e1d6 100644 --- a/packages/core/src/postgres/postgres-health.ts +++ b/packages/core/src/postgres/postgres-health.ts @@ -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). { table: "tasks", column: "execute_requeue_loop_count", type: "integer" }, { 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 { table: "distributed_task_id_state", column: "prefix", type: "text" }, { table: "distributed_task_id_state", column: "next_sequence", type: "integer" }, diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 8ebea57482..9339674965 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -122,6 +122,7 @@ export const tasks = projectSchema.table("tasks", { */ stuckKillCount: integer("stuck_kill_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), branchConflictRecoveryCount: integer("branch_conflict_recovery_count").default(0), reviewerContextRetryCount: integer("reviewer_context_retry_count").default(0), diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 17461f3fb0..3b3ae73964 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1138,7 +1138,7 @@ export class TaskStore extends EventEmitter { } async updateTask( 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; 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 | 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; 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 | 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, ): Promise { return updateTaskImpl(this, id, updates, runContext); diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index 63cd489941..6306a7c17d 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -52,6 +52,7 @@ export interface TaskRow { executeRequeueLoopCount: number | null; executeRequeueLoopSignature: string | null; postReviewFixCount: number | null; + planReviewReplanCount: number | null; recoveryRetryCount: number | null; taskDoneRetryCount: number | null; worktreeSessionRetryCount: number | null; @@ -210,6 +211,7 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("executeRequeueLoopCount", (task) => task.executeRequeueLoopCount ?? 0), defineTaskColumn("executeRequeueLoopSignature", (task) => task.executeRequeueLoopSignature ?? null), defineTaskColumn("postReviewFixCount", (task) => task.postReviewFixCount ?? 0), + defineTaskColumn("planReviewReplanCount", (task) => task.planReviewReplanCount ?? 0), defineTaskColumn("recoveryRetryCount", (task) => task.recoveryRetryCount ?? null), defineTaskColumn("taskDoneRetryCount", (task) => task.taskDoneRetryCount ?? 0), defineTaskColumn("worktreeSessionRetryCount", (task) => task.worktreeSessionRetryCount ?? 0), diff --git a/packages/core/src/task-store/remaining-ops-2.ts b/packages/core/src/task-store/remaining-ops-2.ts index a79e63cb1f..b9c43ce447 100644 --- a/packages/core/src/task-store/remaining-ops-2.ts +++ b/packages/core/src/task-store/remaining-ops-2.ts @@ -43,7 +43,7 @@ export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, li "modelPresetId", "modelProvider", "modelId", "validatorModelProvider", "validatorModelId", "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", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", diff --git a/packages/core/src/task-store/remaining-ops-3.ts b/packages/core/src/task-store/remaining-ops-3.ts index 6f9664a3e1..16601378a0 100644 --- a/packages/core/src/task-store/remaining-ops-3.ts +++ b/packages/core/src/task-store/remaining-ops-3.ts @@ -34,7 +34,7 @@ export function getTaskSelectClauseImpl2(store: TaskStore, slim: boolean, tableA "modelPresetId", "modelProvider", "modelId", "validatorModelProvider", "validatorModelId", "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", "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", diff --git a/packages/core/src/task-store/remaining-ops-6.ts b/packages/core/src/task-store/remaining-ops-6.ts index e9faf6e099..6974b740cb 100644 --- a/packages/core/src/task-store/remaining-ops-6.ts +++ b/packages/core/src/task-store/remaining-ops-6.ts @@ -624,7 +624,7 @@ export async function resetPromptCheckboxesImpl(store: TaskStore, dir: string): export async function updateTaskImpl(store: TaskStore, 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; 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 | 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; 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 | 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, ): Promise { /* diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 03d201dbad..8b09097411 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -106,6 +106,7 @@ export function rowToTask(row: TaskRow): Task { executeRequeueLoopCount: row.executeRequeueLoopCount ?? undefined, executeRequeueLoopSignature: row.executeRequeueLoopSignature || undefined, postReviewFixCount: row.postReviewFixCount ?? undefined, + planReviewReplanCount: row.planReviewReplanCount ?? undefined, recoveryRetryCount: row.recoveryRetryCount ?? undefined, taskDoneRetryCount: row.taskDoneRetryCount ?? undefined, worktreeSessionRetryCount: row.worktreeSessionRetryCount ?? undefined, diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 645af693c4..fd7bf0c7d4 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -389,6 +389,11 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } else if (updates.postReviewFixCount !== undefined) { task.postReviewFixCount = updates.postReviewFixCount; } + if (updates.planReviewReplanCount === null) { + task.planReviewReplanCount = undefined; + } else if (updates.planReviewReplanCount !== undefined) { + task.planReviewReplanCount = updates.planReviewReplanCount; + } if (updates.recoveryRetryCount === null) { task.recoveryRetryCount = undefined; } else if (updates.recoveryRetryCount !== undefined) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b269175481..8140aaa4f9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2481,6 +2481,16 @@ export interface Task { * Review defaults to unbounded recovery so ordinary REVISE feedback does not * terminal-fail the task. */ 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. * Distinct from `mergeRetries` (merge-conflict-specific). Incremented by the * 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 * 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: * FN-7569 — records the computePlanApprovalFingerprint (packages/core/src/plan-approval.ts) 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 new file mode 100644 index 0000000000..32f441d8a2 --- /dev/null +++ b/packages/engine/src/__tests__/triage-plan-review-replan-cap.test.ts @@ -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(); + return createEngineCoreMock(() => Promise.resolve(original)); +}); + +async function createFixtureRoot(): Promise { + return mkdtemp(join(tmpdir(), "fusion-triage-plan-review-replan-cap-")); +} + +async function cleanupFixtureRoot(rootDir: string): Promise { + await rm(rootDir, { recursive: true, force: true }); +} + +function createRetryTask(overrides: Partial = {}): 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 = {}): 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 { + 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 { + 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"); + }); +}); diff --git a/packages/engine/src/__tests__/triage-replan-feedback-from-plan-review.test.ts b/packages/engine/src/__tests__/triage-replan-feedback-from-plan-review.test.ts new file mode 100644 index 0000000000..85e2d88e43 --- /dev/null +++ b/packages/engine/src/__tests__/triage-replan-feedback-from-plan-review.test.ts @@ -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 { + 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 = {}) { + 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) => { + 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 { + 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); + }); +}); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index c948a0621c..cdf7a9e934 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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_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( lines: string[], opts: { maxChars?: number } = {}, @@ -1258,6 +1272,28 @@ export class TriageProcessor { 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( `${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 }); } + /* + 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 { + 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 + 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 = { + 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"> { if (!this.isPlanReviewEnabled(task)) { return "approved"; @@ -2048,12 +2133,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.store.updateTask(task.id, { - status: "needs-replan", - error: null, - recoveryRetryCount: null, - nextRecoveryAt: null, - }); + await this.blockAfterPlanReviewRevise(task, diagnostic); return "blocked"; } } @@ -2117,6 +2197,11 @@ export class TriageProcessor { startedAt, 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); return "approved"; } @@ -2134,17 +2219,13 @@ export class TriageProcessor { completedAt, }); 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( task.id, "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, { - status: "needs-replan", - error: null, - recoveryRetryCount: null, - nextRecoveryAt: null, - }); + await this.blockAfterPlanReviewRevise(task, reviseFeedback); return "blocked"; }