feat(FN-4333): emergency hot-fix for post-merge audit blocks
Stop the post-merge audit from parking tasks as `failed` when deterministic merge verification already proved the merged tree. Adds `postMergeAuditMode` project setting (`block` | `warn` | `off`, default `block`) and a verified-tree short-circuit for rebase-strategy overlap-only findings. - packages/engine/src/merger.ts: new `resolvePostMergeAuditAction` helper + audit call site gated on `postMergeAuditMode` and verification-cache lookup. Rebase + overlap-only + verified-tree → pass through with a short-circuit log entry instead of throwing SquashAuditError. - packages/core/src/types.ts, settings-schema.ts, index.ts: new `PostMergeAuditMode` type, `postMergeAuditMode` field on Settings, default `block`, exports `normalizePostMergeAuditMode`. - packages/engine/src/__tests__/post-merge-audit-action.test.ts: unit tests for the decision helper. - packages/dashboard/app/components/SettingsModal.tsx: settings UI row. - .changeset: minor bump for @runfusion/fusion. The FN-3936 silent-drop guard is preserved: duplicate-subject findings still block in `block` mode and squash-strategy audits still block (no equivalent deterministic guarantee). Fusion-Task-Id: FN-4333
This commit is contained in:
11
.changeset/fn-4333-post-merge-audit-hotfix.md
Normal file
11
.changeset/fn-4333-post-merge-audit-hotfix.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Stop the post-merge audit from parking tasks as `failed` when deterministic merge verification already proved the resulting tree. Adds `postMergeAuditMode` project setting (`"block"` | `"warn"` | `"off"`, default `"block"`):
|
||||||
|
|
||||||
|
- A `rebase`-strategy audit that flags only touched-file overlap risks now passes through when the merged tree has a verification cache hit — silent drops are impossible by construction in that case.
|
||||||
|
- `warn` mode logs audit findings on the agent log but auto-completes the merge.
|
||||||
|
- `off` skips the audit entirely.
|
||||||
|
|
||||||
|
Duplicate-subject findings still block in `block` mode and squash-strategy audits still block (no equivalent deterministic guarantee). The FN-3936 silent-drop guard is preserved.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
||||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
mergeDiffVolumeThreshold: undefined,
|
mergeDiffVolumeThreshold: undefined,
|
||||||
mergeDiffVolumeAllowlist: undefined,
|
mergeDiffVolumeAllowlist: undefined,
|
||||||
mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
|
mergeStrategyOverlapBehavior: "flip-to-prefer-branch",
|
||||||
|
postMergeAuditMode: "block",
|
||||||
workflowStepTimeoutMs: 360_000,
|
workflowStepTimeoutMs: 360_000,
|
||||||
workflowRevisionForkOnScopeMismatch: true,
|
workflowRevisionForkOnScopeMismatch: true,
|
||||||
strictScopeEnforcement: false,
|
strictScopeEnforcement: false,
|
||||||
|
|||||||
@@ -197,6 +197,18 @@ export function normalizeMergeStrategyOverlapBehavior(
|
|||||||
? value as MergeStrategyOverlapBehavior
|
? value as MergeStrategyOverlapBehavior
|
||||||
: "flip-to-prefer-branch";
|
: "flip-to-prefer-branch";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const POST_MERGE_AUDIT_MODES = ["block", "warn", "off"] as const;
|
||||||
|
|
||||||
|
/** Controls how the merger reacts to a dirty post-merge audit (FN-4333). */
|
||||||
|
export type PostMergeAuditMode = (typeof POST_MERGE_AUDIT_MODES)[number];
|
||||||
|
|
||||||
|
export function normalizePostMergeAuditMode(value: unknown): PostMergeAuditMode {
|
||||||
|
return typeof value === "string"
|
||||||
|
&& (POST_MERGE_AUDIT_MODES as readonly string[]).includes(value)
|
||||||
|
? (value as PostMergeAuditMode)
|
||||||
|
: "block";
|
||||||
|
}
|
||||||
/** Policy for handling task execution when the selected node is unavailable/unhealthy. */
|
/** Policy for handling task execution when the selected node is unavailable/unhealthy. */
|
||||||
export type UnavailableNodePolicy = "block" | "fallback-local";
|
export type UnavailableNodePolicy = "block" | "fallback-local";
|
||||||
|
|
||||||
@@ -2242,6 +2254,15 @@ export interface ProjectSettings {
|
|||||||
/** Controls overlap protection when `mergeConflictStrategy="smart-prefer-main"`
|
/** Controls overlap protection when `mergeConflictStrategy="smart-prefer-main"`
|
||||||
* reaches its Attempt 3 fallback. Default: "flip-to-prefer-branch". */
|
* reaches its Attempt 3 fallback. Default: "flip-to-prefer-branch". */
|
||||||
mergeStrategyOverlapBehavior?: MergeStrategyOverlapBehavior;
|
mergeStrategyOverlapBehavior?: MergeStrategyOverlapBehavior;
|
||||||
|
/** Controls how the merger reacts to a dirty post-merge / post-rebase audit (FN-4333).
|
||||||
|
* - "block" (default): throw `SquashAuditError`, park task as failed (today's behavior).
|
||||||
|
* - "warn": log audit findings on the agent log but auto-complete the merge.
|
||||||
|
* - "off": skip the post-merge audit entirely.
|
||||||
|
*
|
||||||
|
* Regardless of mode, the merger short-circuits overlap-only findings on the
|
||||||
|
* rebase-strategy path when deterministic merge verification has already proven
|
||||||
|
* the resulting tree (silent drops are impossible by construction in that case). */
|
||||||
|
postMergeAuditMode?: PostMergeAuditMode;
|
||||||
/** Wall-clock timeout (ms) for a single pre-merge workflow step's AI call.
|
/** Wall-clock timeout (ms) for a single pre-merge workflow step's AI call.
|
||||||
* When a step exceeds this, the session is aborted and the executor is
|
* When a step exceeds this, the session is aborted and the executor is
|
||||||
* given one shot to retry with the configured fallback model before the
|
* given one shot to retry with the configured fallback model before the
|
||||||
|
|||||||
@@ -4060,6 +4060,26 @@ export function SettingsModal({
|
|||||||
When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work.
|
When using smart-prefer-main, automatically prefer the branch side for files that main has recently modified to avoid silently discarding branch work.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="postMergeAuditMode">Post-Merge Audit Mode</label>
|
||||||
|
<select
|
||||||
|
id="postMergeAuditMode"
|
||||||
|
value={form.postMergeAuditMode ?? "block"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
postMergeAuditMode: e.target.value as "block" | "warn" | "off",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="block">Block — refuse to auto-complete merges with duplicate-subject or touched-file overlap risks (default)</option>
|
||||||
|
<option value="warn">Warn — log audit findings but auto-complete the merge</option>
|
||||||
|
<option value="off">Off — skip the post-merge audit entirely</option>
|
||||||
|
</select>
|
||||||
|
<small>
|
||||||
|
Controls how the post-squash / post-rebase audit reacts to risk findings. Regardless of mode, the audit short-circuits overlap-only findings on rebase merges when deterministic verification has already proven the tree — those cannot have produced silent drops. Switch to Warn or Off only if you trust your branches don't silently drop edits.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="pushAfterMerge" className="checkbox-label">
|
<label htmlFor="pushAfterMerge" className="checkbox-label">
|
||||||
<input
|
<input
|
||||||
|
|||||||
149
packages/engine/src/__tests__/post-merge-audit-action.test.ts
Normal file
149
packages/engine/src/__tests__/post-merge-audit-action.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
resolvePostMergeAuditAction,
|
||||||
|
type PostMergeAuditAction,
|
||||||
|
} from "../merger.js";
|
||||||
|
import type {
|
||||||
|
SquashAuditFindings,
|
||||||
|
SquashAuditTouchedFileOverlapFinding,
|
||||||
|
SquashAuditDuplicateSubjectFinding,
|
||||||
|
} from "../merger-squash-audit.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FN-4333 — unit tests for the post-merge audit decision helper.
|
||||||
|
*
|
||||||
|
* `resolvePostMergeAuditAction` decides whether a dirty audit should block
|
||||||
|
* the merge or be passed through. The merger uses this to apply the
|
||||||
|
* deterministic-verification short-circuit + the `postMergeAuditMode`
|
||||||
|
* setting without spinning up real git/store state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function overlap(file: string): SquashAuditTouchedFileOverlapFinding {
|
||||||
|
return {
|
||||||
|
type: "touched-file-overlap",
|
||||||
|
file,
|
||||||
|
recentMainCommits: [{ sha: "abcdef12", subject: "chore: recent main edit" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateSubject(subject: string): SquashAuditDuplicateSubjectFinding {
|
||||||
|
return { type: "duplicate-subject", subject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findings(opts: {
|
||||||
|
strategy: "squash" | "rebase";
|
||||||
|
duplicates?: SquashAuditDuplicateSubjectFinding[];
|
||||||
|
overlaps?: SquashAuditTouchedFileOverlapFinding[];
|
||||||
|
}): SquashAuditFindings {
|
||||||
|
const duplicates = opts.duplicates ?? [];
|
||||||
|
const overlaps = opts.overlaps ?? [];
|
||||||
|
const list = [...duplicates, ...overlaps];
|
||||||
|
const base = {
|
||||||
|
parentSha: "0000000000000000000000000000000000000000",
|
||||||
|
lookback: 30,
|
||||||
|
branchSubjects: [],
|
||||||
|
recentMainSubjects: [],
|
||||||
|
duplicateSubjects: duplicates,
|
||||||
|
touchedFiles: overlaps.map((o) => o.file),
|
||||||
|
touchedFileOverlaps: overlaps,
|
||||||
|
findings: list,
|
||||||
|
issueCount: list.length,
|
||||||
|
clean: list.length === 0,
|
||||||
|
};
|
||||||
|
if (opts.strategy === "rebase") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
strategy: "rebase",
|
||||||
|
rangeBaseSha: "1".repeat(40),
|
||||||
|
rangeHeadSha: "2".repeat(40),
|
||||||
|
auditTargetLabel: "11111111..22222222",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
strategy: "squash",
|
||||||
|
squashSha: "3".repeat(40),
|
||||||
|
squashSubject: "feat: squash",
|
||||||
|
auditTargetLabel: "33333333",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolvePostMergeAuditAction (FN-4333)", () => {
|
||||||
|
it("passes through when audit is clean (defensive default)", () => {
|
||||||
|
const result = resolvePostMergeAuditAction({
|
||||||
|
mode: "block",
|
||||||
|
strategy: "rebase",
|
||||||
|
findings: findings({ strategy: "rebase" }),
|
||||||
|
isTreeVerified: false,
|
||||||
|
});
|
||||||
|
expect(result.action).toBe("pass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks duplicate-subject findings in block mode regardless of verification", () => {
|
||||||
|
const result = resolvePostMergeAuditAction({
|
||||||
|
mode: "block",
|
||||||
|
strategy: "rebase",
|
||||||
|
findings: findings({
|
||||||
|
strategy: "rebase",
|
||||||
|
duplicates: [duplicateSubject("feat: collide")],
|
||||||
|
overlaps: [overlap("docs/README.md")],
|
||||||
|
}),
|
||||||
|
isTreeVerified: true,
|
||||||
|
});
|
||||||
|
expect(result).toEqual<PostMergeAuditAction>({ action: "block", reason: "mode-block" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-circuits rebase-strategy overlap-only findings when the tree is verified", () => {
|
||||||
|
const result = resolvePostMergeAuditAction({
|
||||||
|
mode: "block",
|
||||||
|
strategy: "rebase",
|
||||||
|
findings: findings({ strategy: "rebase", overlaps: [overlap("docs/README.md")] }),
|
||||||
|
isTreeVerified: true,
|
||||||
|
});
|
||||||
|
expect(result).toEqual<PostMergeAuditAction>({ action: "pass", reason: "verified-short-circuit" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT short-circuit overlap-only findings when the tree was not verified", () => {
|
||||||
|
const result = resolvePostMergeAuditAction({
|
||||||
|
mode: "block",
|
||||||
|
strategy: "rebase",
|
||||||
|
findings: findings({ strategy: "rebase", overlaps: [overlap("docs/README.md")] }),
|
||||||
|
isTreeVerified: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual<PostMergeAuditAction>({ action: "block", reason: "mode-block" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT short-circuit squash-strategy overlap-only findings even when verified (no deterministic guarantee)", () => {
|
||||||
|
const result = resolvePostMergeAuditAction({
|
||||||
|
mode: "block",
|
||||||
|
strategy: "squash",
|
||||||
|
findings: findings({ strategy: "squash", overlaps: [overlap("docs/README.md")] }),
|
||||||
|
isTreeVerified: true,
|
||||||
|
});
|
||||||
|
expect(result).toEqual<PostMergeAuditAction>({ action: "block", reason: "mode-block" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes any dirty audit in warn mode", () => {
|
||||||
|
const result = resolvePostMergeAuditAction({
|
||||||
|
mode: "warn",
|
||||||
|
strategy: "squash",
|
||||||
|
findings: findings({
|
||||||
|
strategy: "squash",
|
||||||
|
duplicates: [duplicateSubject("feat: collide")],
|
||||||
|
overlaps: [overlap("packages/dashboard/app/x.tsx")],
|
||||||
|
}),
|
||||||
|
isTreeVerified: false,
|
||||||
|
});
|
||||||
|
expect(result).toEqual<PostMergeAuditAction>({ action: "pass", reason: "mode-warn" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers verified-short-circuit reason over mode-warn when both would pass", () => {
|
||||||
|
const result = resolvePostMergeAuditAction({
|
||||||
|
mode: "warn",
|
||||||
|
strategy: "rebase",
|
||||||
|
findings: findings({ strategy: "rebase", overlaps: [overlap("a.txt")] }),
|
||||||
|
isTreeVerified: true,
|
||||||
|
});
|
||||||
|
expect(result).toEqual<PostMergeAuditAction>({ action: "pass", reason: "verified-short-circuit" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
getTaskMergeBlocker,
|
getTaskMergeBlocker,
|
||||||
normalizeMergeConflictStrategy,
|
normalizeMergeConflictStrategy,
|
||||||
normalizeMergeStrategyOverlapBehavior,
|
normalizeMergeStrategyOverlapBehavior,
|
||||||
|
normalizePostMergeAuditMode,
|
||||||
resolveTaskMergeTarget,
|
resolveTaskMergeTarget,
|
||||||
resolveTitleSummarizerSettingsModel,
|
resolveTitleSummarizerSettingsModel,
|
||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
@@ -53,6 +54,7 @@ import {
|
|||||||
type AgentPromptsConfig,
|
type AgentPromptsConfig,
|
||||||
type CanonicalMergeConflictStrategy,
|
type CanonicalMergeConflictStrategy,
|
||||||
type DirectMergeCommitStrategy,
|
type DirectMergeCommitStrategy,
|
||||||
|
type PostMergeAuditMode,
|
||||||
type TaskSourceIssue,
|
type TaskSourceIssue,
|
||||||
type Task,
|
type Task,
|
||||||
type AutostashOrphanRecord,
|
type AutostashOrphanRecord,
|
||||||
@@ -4599,6 +4601,58 @@ function shouldRunPostMergeAudit(
|
|||||||
return (result.autoResolvedCount ?? 0) > 0 || result.attemptsMade === 3;
|
return (result.autoResolvedCount ?? 0) > 0 || result.attemptsMade === 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what to do with a dirty post-merge audit (FN-4333 hot-fix).
|
||||||
|
*
|
||||||
|
* Three modes (`postMergeAuditMode` setting):
|
||||||
|
* - `"off"` — caller should skip auditing entirely (handled before this fn).
|
||||||
|
* - `"warn"` — log findings on the agent log but proceed; never throws.
|
||||||
|
* - `"block"` — today's behavior: throw `SquashAuditError` and park the task,
|
||||||
|
* EXCEPT for the deterministic-verification short-circuit:
|
||||||
|
* rebase-strategy + overlap-only findings + a verified merged tree
|
||||||
|
* cannot have produced silent drops (the tree is provably the
|
||||||
|
* rebase output by construction), so we pass through clean.
|
||||||
|
*
|
||||||
|
* Pure / side-effect-free so it is unit-testable without spinning up a real
|
||||||
|
* merger flow. The merger call site uses the returned action to decide whether
|
||||||
|
* to throw or fall through.
|
||||||
|
*/
|
||||||
|
export type PostMergeAuditAction =
|
||||||
|
| { action: "pass"; reason: "verified-short-circuit" | "mode-warn" }
|
||||||
|
| { action: "block"; reason: "mode-block" };
|
||||||
|
|
||||||
|
export function resolvePostMergeAuditAction(opts: {
|
||||||
|
mode: PostMergeAuditMode;
|
||||||
|
strategy: PostMergeAuditStrategy;
|
||||||
|
findings: SquashAuditFindings;
|
||||||
|
isTreeVerified: boolean;
|
||||||
|
}): PostMergeAuditAction {
|
||||||
|
if (opts.findings.clean) {
|
||||||
|
// Caller should never invoke this on a clean audit, but be defensive.
|
||||||
|
return { action: "pass", reason: "mode-warn" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlapOnly =
|
||||||
|
opts.findings.duplicateSubjects.length === 0
|
||||||
|
&& opts.findings.touchedFileOverlaps.length > 0;
|
||||||
|
|
||||||
|
// Stage 1 short-circuit: a verified rebase tree with overlap-only findings
|
||||||
|
// cannot have produced silent drops. Pass regardless of mode (warn/block).
|
||||||
|
if (
|
||||||
|
opts.strategy === "rebase"
|
||||||
|
&& overlapOnly
|
||||||
|
&& opts.isTreeVerified
|
||||||
|
) {
|
||||||
|
return { action: "pass", reason: "verified-short-circuit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.mode === "warn") {
|
||||||
|
return { action: "pass", reason: "mode-warn" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "block", reason: "mode-block" };
|
||||||
|
}
|
||||||
|
|
||||||
function buildPostMergeAuditBlockingMessage(taskId: string, findings: SquashAuditFindings): string {
|
function buildPostMergeAuditBlockingMessage(taskId: string, findings: SquashAuditFindings): string {
|
||||||
const riskParts: string[] = [];
|
const riskParts: string[] = [];
|
||||||
if (findings.duplicateSubjects.length > 0) {
|
if (findings.duplicateSubjects.length > 0) {
|
||||||
@@ -6665,7 +6719,12 @@ export async function aiMergeTask(
|
|||||||
const recordedSha = (isEmptyCommit || mergeWasEmpty) ? undefined : commitSha;
|
const recordedSha = (isEmptyCommit || mergeWasEmpty) ? undefined : commitSha;
|
||||||
|
|
||||||
const auditSha = recordedSha;
|
const auditSha = recordedSha;
|
||||||
if (auditSha && shouldRunPostMergeAudit(selectedPostMergeAuditStrategy, result, mergeWasEmpty, isEmptyCommit, auditSha)) {
|
const postMergeAuditMode = normalizePostMergeAuditMode(settings.postMergeAuditMode);
|
||||||
|
if (
|
||||||
|
auditSha
|
||||||
|
&& postMergeAuditMode !== "off"
|
||||||
|
&& shouldRunPostMergeAudit(selectedPostMergeAuditStrategy, result, mergeWasEmpty, isEmptyCommit, auditSha)
|
||||||
|
) {
|
||||||
const auditFindings = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
|
const auditFindings = selectedPostMergeAuditStrategy === "rebase" && rebaseMergeBaseSha
|
||||||
? await auditSquashMerge({
|
? await auditSquashMerge({
|
||||||
rootDir,
|
rootDir,
|
||||||
@@ -6679,24 +6738,72 @@ export async function aiMergeTask(
|
|||||||
squashSha: auditSha,
|
squashSha: auditSha,
|
||||||
});
|
});
|
||||||
if (!auditFindings.clean) {
|
if (!auditFindings.clean) {
|
||||||
const auditError = new SquashAuditError(taskId, auditSha, auditFindings);
|
// FN-4333: a verified rebase tree with overlap-only findings cannot have
|
||||||
|
// produced silent drops by construction. Check the verification cache to
|
||||||
|
// detect that case and pass through. `warn` mode passes any dirty audit.
|
||||||
|
let isTreeVerified = false;
|
||||||
|
try {
|
||||||
|
const { stdout: treeShaOut } = await execAsync(
|
||||||
|
`git rev-parse ${auditSha}^{tree}`,
|
||||||
|
{ cwd: rootDir, encoding: "utf-8" },
|
||||||
|
);
|
||||||
|
const treeSha = treeShaOut.trim();
|
||||||
|
if (treeSha) {
|
||||||
|
const cacheHit = store.getVerificationCacheHit(
|
||||||
|
treeSha,
|
||||||
|
effectiveTestCommand ?? "",
|
||||||
|
effectiveBuildCommand ?? "",
|
||||||
|
);
|
||||||
|
isTreeVerified = Boolean(cacheHit);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
mergerLog.warn(
|
||||||
|
`${taskId}: failed to resolve tree sha for audit verification short-circuit: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decision = resolvePostMergeAuditAction({
|
||||||
|
mode: postMergeAuditMode,
|
||||||
|
strategy: selectedPostMergeAuditStrategy,
|
||||||
|
findings: auditFindings,
|
||||||
|
isTreeVerified,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision.action === "block") {
|
||||||
|
const auditError = new SquashAuditError(taskId, auditSha, auditFindings);
|
||||||
|
await store.appendAgentLog(
|
||||||
|
taskId,
|
||||||
|
auditError.message,
|
||||||
|
"tool_error",
|
||||||
|
formatSquashAuditAgentLog(auditFindings),
|
||||||
|
"merger",
|
||||||
|
);
|
||||||
|
await store.updateTask(taskId, { status: null });
|
||||||
|
throw auditError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passLabel = decision.reason === "verified-short-circuit"
|
||||||
|
? `${selectedPostMergeAuditStrategy === "rebase" ? "post-rebase" : "post-squash"} audit overlap cleared by deterministic verification`
|
||||||
|
: `${selectedPostMergeAuditStrategy === "rebase" ? "post-rebase" : "post-squash"} audit found ${auditFindings.issueCount} risk(s) — continuing (postMergeAuditMode=warn)`;
|
||||||
await store.appendAgentLog(
|
await store.appendAgentLog(
|
||||||
taskId,
|
taskId,
|
||||||
auditError.message,
|
passLabel,
|
||||||
"tool_error",
|
"text",
|
||||||
formatSquashAuditAgentLog(auditFindings),
|
formatSquashAuditAgentLog(auditFindings),
|
||||||
"merger",
|
"merger",
|
||||||
);
|
);
|
||||||
await store.updateTask(taskId, { status: null });
|
mergerLog.log(`${taskId}: ${passLabel}`);
|
||||||
throw auditError;
|
} else {
|
||||||
|
await store.appendAgentLog(
|
||||||
|
taskId,
|
||||||
|
selectedPostMergeAuditStrategy === "rebase" ? "post-rebase range audit clean" : "post-squash audit clean",
|
||||||
|
"text",
|
||||||
|
undefined,
|
||||||
|
"merger",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await store.appendAgentLog(
|
} else if (auditSha && postMergeAuditMode === "off") {
|
||||||
taskId,
|
mergerLog.log(`${taskId}: post-merge audit skipped (postMergeAuditMode=off)`);
|
||||||
selectedPostMergeAuditStrategy === "rebase" ? "post-rebase range audit clean" : "post-squash audit clean",
|
|
||||||
"text",
|
|
||||||
undefined,
|
|
||||||
"merger",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (isEmptyCommit) {
|
if (isEmptyCommit) {
|
||||||
mergerLog.warn(
|
mergerLog.warn(
|
||||||
|
|||||||
Reference in New Issue
Block a user