feat(FN-4108): persist scope override fields (+4 more)
Commits merged: - test(FN-4073): finalize invariant regression coverage - feat(FN-4073): complete Step 4 — document merge invariant - feat(FN-4073): complete Step 3 — enforce squash file-scope invariant - feat(FN-4073): complete Step 2 — add file-scope invariant helper - feat(FN-4073): complete Step 1 — persist scope override fields Files changed: .changeset/FN-4073-file-scope-invariant.md | 9 + AGENTS.md | 6 + packages/core/src/__tests__/store-update.test.ts | 18 ++ packages/core/src/db.ts | 2 + packages/core/src/store.ts | 34 +- packages/core/src/types.ts | 8 + .../__tests__/merger-file-scope-invariant.test.ts | 357 +++++++++++++++++++++ .../engine/src/__tests__/merger-test-helpers.ts | 5 + packages/engine/src/merger.ts | 155 ++++++++- 9 files changed, 582 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-4108
This commit is contained in:
9
.changeset/FN-4073-file-scope-invariant.md
Normal file
9
.changeset/FN-4073-file-scope-invariant.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Merger now refuses to land a squash whose staged diff has zero overlap with the
|
||||||
|
task's declared `## File Scope`. Tasks can opt out by setting
|
||||||
|
`task.scopeOverride = true` (with optional `task.scopeOverrideReason`).
|
||||||
|
Violating squashes leave the task in `in-review` with a structured agent-log
|
||||||
|
entry instead of silently shipping out-of-scope changes.
|
||||||
@@ -223,6 +223,12 @@ After any squash that auto-resolved conflicts, the merger now runs the post-squa
|
|||||||
|
|
||||||
Before those auto-resolved squash commits are written, the merger also runs a per-file diff-volume gate: it compares each file's staged squash delta against the branch's net delta vs its merge-base, and blocks the merge in `in-review` when a non-allowlisted file loses too much branch volume. This is the pre-commit guard against FN-3936-style silent drops where fallback resolution kept a branch's commit message but discarded the branch's main file edits.
|
Before those auto-resolved squash commits are written, the merger also runs a per-file diff-volume gate: it compares each file's staged squash delta against the branch's net delta vs its merge-base, and blocks the merge in `in-review` when a non-allowlisted file loses too much branch volume. This is the pre-commit guard against FN-3936-style silent drops where fallback resolution kept a branch's commit message but discarded the branch's main file edits.
|
||||||
|
|
||||||
|
### File-Scope invariant on squash merges
|
||||||
|
|
||||||
|
Every squash commit path now enforces a file-scope invariant immediately before writing the commit: the staged file set must overlap the task's declared `## File Scope` from `PROMPT.md`. The invariant runs on the standard squash path, the Attempt 3 `-X ours/theirs` fallback, and the verification-fix rebuild/finalize path. When the staged files have zero overlap with a non-empty declared scope, the merger throws a structured `FileScopeViolationError`, logs the declared scope + staged files to the merger agent log, resets the pre-squash state, and leaves the task in `in-review` for inspection instead of landing the commit.
|
||||||
|
|
||||||
|
Tasks can opt out per task via `task.scopeOverride = true`; when present, the merger bypasses the invariant and logs `task.scopeOverrideReason` when provided. Empty declared file scopes are not enforced.
|
||||||
|
|
||||||
When `mergeConflictStrategy="smart-prefer-main"`, the merger also runs an overlap guard before the Attempt 3 `-X ours` fallback. If recent `main` commits (30-commit lookback) touched files the task branch also changed, the default `mergeStrategyOverlapBehavior="flip-to-prefer-branch"` makes those overlapping files prefer the task branch instead of silently discarding branch hardening; `warn-only` preserves the legacy fallback while logging the risk, and `ignore` disables the guard.
|
When `mergeConflictStrategy="smart-prefer-main"`, the merger also runs an overlap guard before the Attempt 3 `-X ours` fallback. If recent `main` commits (30-commit lookback) touched files the task branch also changed, the default `mergeStrategyOverlapBehavior="flip-to-prefer-branch"` makes those overlapping files prefer the task branch instead of silently discarding branch hardening; `warn-only` preserves the legacy fallback while logging the risk, and `ignore` disables the guard.
|
||||||
|
|
||||||
For manual follow-up, standalone auditing, or post-incident inspection, the script remains available:
|
For manual follow-up, standalone auditing, or post-incident inspection, the script remains available:
|
||||||
|
|||||||
@@ -199,6 +199,24 @@ describe("TaskStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("updateTask — scope override", () => {
|
||||||
|
it("persists scopeOverride and scopeOverrideReason via updateTask", async () => {
|
||||||
|
const task = await store.createTask({ title: "Scope override task", description: "A task" });
|
||||||
|
|
||||||
|
const updated = await store.updateTask(task.id, {
|
||||||
|
scopeOverride: true,
|
||||||
|
scopeOverrideReason: "hotfix",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.scopeOverride).toBe(true);
|
||||||
|
expect(updated.scopeOverrideReason).toBe("hotfix");
|
||||||
|
|
||||||
|
const fetched = await store.getTask(task.id);
|
||||||
|
expect(fetched.scopeOverride).toBe(true);
|
||||||
|
expect(fetched.scopeOverrideReason).toBe("hotfix");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("updateTask — assigneeUserId", () => {
|
describe("updateTask — assigneeUserId", () => {
|
||||||
it("sets assigneeUserId via updateTask", async () => {
|
it("sets assigneeUserId via updateTask", async () => {
|
||||||
const task = await store.createTask({ title: "User task", description: "A task" });
|
const task = await store.createTask({ title: "User task", description: "A task" });
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
modifiedFiles TEXT DEFAULT '[]',
|
modifiedFiles TEXT DEFAULT '[]',
|
||||||
missionId TEXT,
|
missionId TEXT,
|
||||||
sliceId TEXT,
|
sliceId TEXT,
|
||||||
|
scopeOverride INTEGER,
|
||||||
|
scopeOverrideReason TEXT,
|
||||||
assignedAgentId TEXT,
|
assignedAgentId TEXT,
|
||||||
pausedByAgentId TEXT,
|
pausedByAgentId TEXT,
|
||||||
assigneeUserId TEXT,
|
assigneeUserId TEXT,
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ interface TaskRow {
|
|||||||
modifiedFiles: string | null;
|
modifiedFiles: string | null;
|
||||||
missionId: string | null;
|
missionId: string | null;
|
||||||
sliceId: string | null;
|
sliceId: string | null;
|
||||||
|
scopeOverride: number | null;
|
||||||
|
scopeOverrideReason: string | null;
|
||||||
assignedAgentId: string | null;
|
assignedAgentId: string | null;
|
||||||
pausedByAgentId: string | null;
|
pausedByAgentId: string | null;
|
||||||
assigneeUserId: string | null;
|
assigneeUserId: string | null;
|
||||||
@@ -876,6 +878,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
executionStartBranch: row.executionStartBranch || undefined,
|
executionStartBranch: row.executionStartBranch || undefined,
|
||||||
branch: row.branch || undefined,
|
branch: row.branch || undefined,
|
||||||
baseCommitSha: row.baseCommitSha || undefined,
|
baseCommitSha: row.baseCommitSha || undefined,
|
||||||
|
scopeOverride: row.scopeOverride ? true : undefined,
|
||||||
|
scopeOverrideReason: row.scopeOverrideReason || undefined,
|
||||||
modelPresetId: row.modelPresetId || undefined,
|
modelPresetId: row.modelPresetId || undefined,
|
||||||
modelProvider: row.modelProvider || undefined,
|
modelProvider: row.modelProvider || undefined,
|
||||||
modelId: row.modelId || undefined,
|
modelId: row.modelId || undefined,
|
||||||
@@ -1230,7 +1234,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
"dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
|
"dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
|
||||||
"attachments", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
"attachments", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||||
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
||||||
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||||
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch",
|
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch",
|
||||||
// `log` is fetched in slim mode so the server can aggregate
|
// `log` is fetched in slim mode so the server can aggregate
|
||||||
@@ -1279,7 +1283,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
"dependencies", "steps", "attachments", "steeringComments",
|
"dependencies", "steps", "attachments", "steeringComments",
|
||||||
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||||
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
||||||
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||||
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch",
|
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch",
|
||||||
];
|
];
|
||||||
@@ -1379,6 +1383,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
toJson(task.modifiedFiles || []),
|
toJson(task.modifiedFiles || []),
|
||||||
task.missionId ?? null,
|
task.missionId ?? null,
|
||||||
task.sliceId ?? null,
|
task.sliceId ?? null,
|
||||||
|
task.scopeOverride ? 1 : null,
|
||||||
|
task.scopeOverrideReason ?? null,
|
||||||
task.assignedAgentId ?? null,
|
task.assignedAgentId ?? null,
|
||||||
task.pausedByAgentId ?? null,
|
task.pausedByAgentId ?? null,
|
||||||
task.assigneeUserId ?? null,
|
task.assigneeUserId ?? null,
|
||||||
@@ -1418,9 +1424,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
dependencies, steps, log, attachments, steeringComments,
|
dependencies, steps, log, attachments, steeringComments,
|
||||||
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
|
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
|
||||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||||
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
|
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
`).run(...this.getTaskPersistValues(task));
|
`).run(...this.getTaskPersistValues(task));
|
||||||
this.db.bumpLastModified();
|
this.db.bumpLastModified();
|
||||||
@@ -1443,9 +1449,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
dependencies, steps, log, attachments, steeringComments,
|
dependencies, steps, log, attachments, steeringComments,
|
||||||
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
|
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
|
||||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||||
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
|
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||||
)
|
)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
lineageId = excluded.lineageId,
|
lineageId = excluded.lineageId,
|
||||||
@@ -1518,6 +1524,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
modifiedFiles = excluded.modifiedFiles,
|
modifiedFiles = excluded.modifiedFiles,
|
||||||
missionId = excluded.missionId,
|
missionId = excluded.missionId,
|
||||||
sliceId = excluded.sliceId,
|
sliceId = excluded.sliceId,
|
||||||
|
scopeOverride = excluded.scopeOverride,
|
||||||
|
scopeOverrideReason = excluded.scopeOverrideReason,
|
||||||
assignedAgentId = excluded.assignedAgentId,
|
assignedAgentId = excluded.assignedAgentId,
|
||||||
pausedByAgentId = excluded.pausedByAgentId,
|
pausedByAgentId = excluded.pausedByAgentId,
|
||||||
assigneeUserId = excluded.assigneeUserId,
|
assigneeUserId = excluded.assigneeUserId,
|
||||||
@@ -2800,6 +2808,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
modelPresetId: input.modelPresetId,
|
modelPresetId: input.modelPresetId,
|
||||||
assignedAgentId: input.assignedAgentId,
|
assignedAgentId: input.assignedAgentId,
|
||||||
assigneeUserId: input.assigneeUserId,
|
assigneeUserId: input.assigneeUserId,
|
||||||
|
scopeOverride: input.scopeOverride === true ? true : undefined,
|
||||||
|
scopeOverrideReason: input.scopeOverrideReason,
|
||||||
nodeId: input.nodeId,
|
nodeId: input.nodeId,
|
||||||
modelProvider: input.modelProvider,
|
modelProvider: input.modelProvider,
|
||||||
modelId: input.modelId,
|
modelId: input.modelId,
|
||||||
@@ -3644,7 +3654,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; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: 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; 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; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | 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; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: 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; 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; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | 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; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||||
runContext?: RunMutationContext,
|
runContext?: RunMutationContext,
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
return this.withTaskLock(id, async () => {
|
return this.withTaskLock(id, async () => {
|
||||||
@@ -3777,6 +3787,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
} else if (updates.assigneeUserId !== undefined) {
|
} else if (updates.assigneeUserId !== undefined) {
|
||||||
task.assigneeUserId = updates.assigneeUserId;
|
task.assigneeUserId = updates.assigneeUserId;
|
||||||
}
|
}
|
||||||
|
if (updates.scopeOverride === null) {
|
||||||
|
task.scopeOverride = undefined;
|
||||||
|
} else if (updates.scopeOverride !== undefined) {
|
||||||
|
task.scopeOverride = updates.scopeOverride || undefined;
|
||||||
|
}
|
||||||
|
if (updates.scopeOverrideReason === null) {
|
||||||
|
task.scopeOverrideReason = undefined;
|
||||||
|
} else if (updates.scopeOverrideReason !== undefined) {
|
||||||
|
task.scopeOverrideReason = updates.scopeOverrideReason;
|
||||||
|
}
|
||||||
if (updates.nodeId === null) {
|
if (updates.nodeId === null) {
|
||||||
task.nodeId = undefined;
|
task.nodeId = undefined;
|
||||||
} else if (updates.nodeId !== undefined) {
|
} else if (updates.nodeId !== undefined) {
|
||||||
|
|||||||
@@ -1162,6 +1162,10 @@ export interface Task {
|
|||||||
baseCommitSha?: string;
|
baseCommitSha?: string;
|
||||||
/** List of files modified by this task (populated during execution) */
|
/** List of files modified by this task (populated during execution) */
|
||||||
modifiedFiles?: string[];
|
modifiedFiles?: string[];
|
||||||
|
/** Opt out of the squash file-scope invariant for this task. */
|
||||||
|
scopeOverride?: boolean;
|
||||||
|
/** Optional justification for bypassing the squash file-scope invariant. */
|
||||||
|
scopeOverrideReason?: string;
|
||||||
/** Mission ID this task is linked to (for mission hierarchy) */
|
/** Mission ID this task is linked to (for mission hierarchy) */
|
||||||
missionId?: string;
|
missionId?: string;
|
||||||
/** Slice ID this task is linked to (for mission hierarchy) */
|
/** Slice ID this task is linked to (for mission hierarchy) */
|
||||||
@@ -1415,6 +1419,10 @@ export interface TaskCreateInput {
|
|||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
/** Optional explicit user assignment for this task (used during review handoff) */
|
/** Optional explicit user assignment for this task (used during review handoff) */
|
||||||
assigneeUserId?: string;
|
assigneeUserId?: string;
|
||||||
|
/** Opt out of the squash file-scope invariant for this task. */
|
||||||
|
scopeOverride?: boolean;
|
||||||
|
/** Optional justification for bypassing the squash file-scope invariant. */
|
||||||
|
scopeOverrideReason?: string;
|
||||||
/** Per-task GitHub issue tracking overrides for Fusion-created linked issues. */
|
/** Per-task GitHub issue tracking overrides for Fusion-created linked issues. */
|
||||||
githubTracking?: Pick<TaskGithubTracking, "enabled" | "repoOverride">;
|
githubTracking?: Pick<TaskGithubTracking, "enabled" | "repoOverride">;
|
||||||
/** Review level for task execution — controls review rigor: 0=None, 1=Plan Only, 2=Plan and Code, 3=Full */
|
/** Review level for task execution — controls review rigor: 0=None, 1=Plan Only, 2=Plan and Code, 3=Full */
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_SETTINGS, type MergeResult, type Task } from "@fusion/core";
|
||||||
|
import { createMockStore, mockedExecSync } from "./merger-test-helpers.js";
|
||||||
|
import {
|
||||||
|
assertSquashOverlapsFileScope,
|
||||||
|
attemptWithSideStrategy,
|
||||||
|
commitOrAmendMergeWithFixes,
|
||||||
|
executeMergeAttempt,
|
||||||
|
FileScopeViolationError,
|
||||||
|
} from "../merger.js";
|
||||||
|
|
||||||
|
function createInvariantStore(scope: string[], taskOverrides: Record<string, unknown> = {}) {
|
||||||
|
const store = createMockStore(taskOverrides) as unknown as {
|
||||||
|
parseFileScopeFromPrompt: ReturnType<typeof vi.fn>;
|
||||||
|
appendAgentLog: ReturnType<typeof vi.fn>;
|
||||||
|
moveTask: ReturnType<typeof vi.fn>;
|
||||||
|
updateTask: ReturnType<typeof vi.fn>;
|
||||||
|
logEntry: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
store.parseFileScopeFromPrompt = vi.fn().mockResolvedValue(scope);
|
||||||
|
store.appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||||
|
store.moveTask = vi.fn().mockResolvedValue(undefined);
|
||||||
|
store.updateTask = vi.fn().mockResolvedValue(undefined);
|
||||||
|
store.logEntry = vi.fn().mockResolvedValue(undefined);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMergeResult(): MergeResult {
|
||||||
|
const task: Task = {
|
||||||
|
id: "FN-4073",
|
||||||
|
description: "Test task",
|
||||||
|
column: "in-review",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
task,
|
||||||
|
branch: "fn/fn-4073",
|
||||||
|
merged: false,
|
||||||
|
worktreeRemoved: false,
|
||||||
|
branchDeleted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assertSquashOverlapsFileScope", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockStagedFiles(files: string[]) {
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr === "git diff --cached --name-only") {
|
||||||
|
return files.join("\n");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("passes without logging when no declared scope exists", async () => {
|
||||||
|
const store = createInvariantStore([]);
|
||||||
|
mockStagedFiles(["packages/engine/src/merger.ts"]);
|
||||||
|
|
||||||
|
await expect(assertSquashOverlapsFileScope({
|
||||||
|
store: store as never,
|
||||||
|
taskId: "FN-4073",
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
task: await (store as any).getTask("FN-4073"),
|
||||||
|
})).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes without logging when staged files fully overlap scope", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||||
|
mockStagedFiles(["packages/engine/src/merger.ts"]);
|
||||||
|
|
||||||
|
await expect(assertSquashOverlapsFileScope({
|
||||||
|
store: store as never,
|
||||||
|
taskId: "FN-4073",
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
task: await (store as any).getTask("FN-4073"),
|
||||||
|
})).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes when staged files partially overlap scope", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||||
|
mockStagedFiles([
|
||||||
|
"packages/engine/src/merger.ts",
|
||||||
|
"packages/core/src/store.ts",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(assertSquashOverlapsFileScope({
|
||||||
|
store: store as never,
|
||||||
|
taskId: "FN-4073",
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
task: await (store as any).getTask("FN-4073"),
|
||||||
|
})).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when staged files have zero overlap with scope", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||||
|
mockStagedFiles(["packages/core/src/store.ts"]);
|
||||||
|
|
||||||
|
await expect(assertSquashOverlapsFileScope({
|
||||||
|
store: store as never,
|
||||||
|
taskId: "FN-4073",
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
task: await (store as any).getTask("FN-4073"),
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
name: "FileScopeViolationError",
|
||||||
|
taskId: "FN-4073",
|
||||||
|
stagedFiles: ["packages/core/src/store.ts"],
|
||||||
|
declaredScope: ["packages/engine/src/merger.ts"],
|
||||||
|
} satisfies Partial<FileScopeViolationError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches nested files against glob entries", async () => {
|
||||||
|
const store = createInvariantStore(["packages/foo/**"]);
|
||||||
|
mockStagedFiles(["packages/foo/src/bar/baz.ts"]);
|
||||||
|
|
||||||
|
await expect(assertSquashOverlapsFileScope({
|
||||||
|
store: store as never,
|
||||||
|
taskId: "FN-4073",
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
task: await (store as any).getTask("FN-4073"),
|
||||||
|
})).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores .changeset files for overlap and still throws without real overlap", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||||
|
mockStagedFiles([".changeset/foo.md"]);
|
||||||
|
|
||||||
|
await expect(assertSquashOverlapsFileScope({
|
||||||
|
store: store as never,
|
||||||
|
taskId: "FN-4073",
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
task: await (store as any).getTask("FN-4073"),
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
name: "FileScopeViolationError",
|
||||||
|
stagedFiles: [".changeset/foo.md"],
|
||||||
|
} satisfies Partial<FileScopeViolationError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bypasses enforcement and logs once when scopeOverride is true", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"], { scopeOverride: true });
|
||||||
|
mockStagedFiles(["packages/core/src/store.ts"]);
|
||||||
|
|
||||||
|
await expect(assertSquashOverlapsFileScope({
|
||||||
|
store: store as never,
|
||||||
|
taskId: "FN-4073",
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
task: await (store as any).getTask("FN-4073"),
|
||||||
|
})).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||||
|
"FN-4073",
|
||||||
|
"file-scope invariant bypassed via scopeOverride",
|
||||||
|
"text",
|
||||||
|
undefined,
|
||||||
|
"merger",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the override reason in the bypass log", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"], {
|
||||||
|
scopeOverride: true,
|
||||||
|
scopeOverrideReason: "hotfix",
|
||||||
|
});
|
||||||
|
mockStagedFiles(["packages/core/src/store.ts"]);
|
||||||
|
|
||||||
|
await expect(assertSquashOverlapsFileScope({
|
||||||
|
store: store as never,
|
||||||
|
taskId: "FN-4073",
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
task: await (store as any).getTask("FN-4073"),
|
||||||
|
})).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||||
|
"FN-4073",
|
||||||
|
"file-scope invariant bypassed via scopeOverride — reason: hotfix",
|
||||||
|
"text",
|
||||||
|
undefined,
|
||||||
|
"merger",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("file-scope invariant wiring", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks the standard merge AI path before the commit when staged files are out of scope", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("git diff --cached --quiet")) return "1";
|
||||||
|
if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = createMergeResult();
|
||||||
|
await expect(executeMergeAttempt({
|
||||||
|
store: store as never,
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
taskId: "FN-4073",
|
||||||
|
branch: "fn/fn-4073",
|
||||||
|
commitLog: "feat: branch work",
|
||||||
|
diffStat: "1 file changed",
|
||||||
|
includeTaskId: true,
|
||||||
|
smartConflictResolution: true,
|
||||||
|
mergeConflictStrategy: "smart-prefer-branch",
|
||||||
|
attemptNum: 1,
|
||||||
|
options: {},
|
||||||
|
result,
|
||||||
|
settings: { ...DEFAULT_SETTINGS },
|
||||||
|
}, { aiWasInvoked: false })).rejects.toBeInstanceOf(FileScopeViolationError);
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||||
|
"FN-4073",
|
||||||
|
expect.stringContaining("File-scope invariant violation"),
|
||||||
|
"tool_error",
|
||||||
|
expect.stringContaining("declaredScope:"),
|
||||||
|
"merger",
|
||||||
|
);
|
||||||
|
expect(mockedExecSync).toHaveBeenCalledWith("git reset --merge", expect.objectContaining({ cwd: "/tmp/root" }));
|
||||||
|
expect(store.moveTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the -X fallback commit when staged files partially overlap scope", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("git merge -X ours --squash")) return "";
|
||||||
|
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "";
|
||||||
|
if (cmdStr.includes("git diff --cached --quiet")) return "1";
|
||||||
|
if (cmdStr === "git diff --cached --name-only") return "packages/engine/src/merger.ts\npackages/core/src/store.ts";
|
||||||
|
if (cmdStr.includes("git commit ")) return "";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(attemptWithSideStrategy({
|
||||||
|
store: store as never,
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
taskId: "FN-4073",
|
||||||
|
branch: "fn/fn-4073",
|
||||||
|
commitLog: "feat: branch work",
|
||||||
|
diffStat: "1 file changed",
|
||||||
|
includeTaskId: true,
|
||||||
|
sourceIssueRef: undefined,
|
||||||
|
smartConflictResolution: true,
|
||||||
|
mergeConflictStrategy: "smart-prefer-main",
|
||||||
|
attemptNum: 3,
|
||||||
|
options: {},
|
||||||
|
result: createMergeResult(),
|
||||||
|
settings: { ...DEFAULT_SETTINGS },
|
||||||
|
}, "ours")).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).not.toHaveBeenCalledWith(
|
||||||
|
"FN-4073",
|
||||||
|
expect.stringContaining("File-scope invariant violation"),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bypasses the -X fallback invariant when scopeOverride is true and logs the reason", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"], {
|
||||||
|
scopeOverride: true,
|
||||||
|
scopeOverrideReason: "hotfix",
|
||||||
|
});
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("git merge -X ours --squash")) return "";
|
||||||
|
if (cmdStr.includes("git diff --name-only --diff-filter=U")) return "";
|
||||||
|
if (cmdStr.includes("git diff --cached --quiet")) return "1";
|
||||||
|
if (cmdStr.includes("git commit ")) return "";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(attemptWithSideStrategy({
|
||||||
|
store: store as never,
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
taskId: "FN-4073",
|
||||||
|
branch: "fn/fn-4073",
|
||||||
|
commitLog: "feat: branch work",
|
||||||
|
diffStat: "1 file changed",
|
||||||
|
includeTaskId: true,
|
||||||
|
sourceIssueRef: undefined,
|
||||||
|
smartConflictResolution: true,
|
||||||
|
mergeConflictStrategy: "smart-prefer-main",
|
||||||
|
attemptNum: 3,
|
||||||
|
options: {},
|
||||||
|
result: createMergeResult(),
|
||||||
|
settings: { ...DEFAULT_SETTINGS },
|
||||||
|
}, "ours")).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||||
|
"FN-4073",
|
||||||
|
"file-scope invariant bypassed via scopeOverride — reason: hotfix",
|
||||||
|
"text",
|
||||||
|
undefined,
|
||||||
|
"merger",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks verification-fix finalization when staged files are out of scope", async () => {
|
||||||
|
const store = createInvariantStore(["packages/engine/src/merger.ts"]);
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr === "git diff --cached --name-only") return "packages/core/src/store.ts";
|
||||||
|
if (cmdStr === "git diff --name-only") return "";
|
||||||
|
if (cmdStr === "git status -z --porcelain") return "";
|
||||||
|
if (cmdStr === "git diff --cached --raw") return "";
|
||||||
|
if (cmdStr === "git rev-parse HEAD") return "head-before";
|
||||||
|
if (cmdStr.includes("git commit ")) return "";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(commitOrAmendMergeWithFixes(
|
||||||
|
"/tmp/root",
|
||||||
|
"FN-4073",
|
||||||
|
"fn/fn-4073",
|
||||||
|
"feat: branch work",
|
||||||
|
true,
|
||||||
|
"head-before",
|
||||||
|
"",
|
||||||
|
"1 file changed",
|
||||||
|
{ ...DEFAULT_SETTINGS },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
new Set(),
|
||||||
|
store as never,
|
||||||
|
)).rejects.toBeInstanceOf(FileScopeViolationError);
|
||||||
|
|
||||||
|
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||||
|
"FN-4073",
|
||||||
|
expect.stringContaining("File-scope invariant violation"),
|
||||||
|
"tool_error",
|
||||||
|
expect.stringContaining("stagedFiles:"),
|
||||||
|
"merger",
|
||||||
|
);
|
||||||
|
expect(mockedExecSync).toHaveBeenCalledWith("git reset --merge", expect.objectContaining({ cwd: "/tmp/root" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,6 +131,8 @@ import {
|
|||||||
parseDiffStat,
|
parseDiffStat,
|
||||||
extractFileScope,
|
extractFileScope,
|
||||||
validateDiffScope,
|
validateDiffScope,
|
||||||
|
assertSquashOverlapsFileScope,
|
||||||
|
FileScopeViolationError,
|
||||||
shouldSyncDependenciesForMerge,
|
shouldSyncDependenciesForMerge,
|
||||||
summarizeVerificationOutput,
|
summarizeVerificationOutput,
|
||||||
inferDefaultTestCommand,
|
inferDefaultTestCommand,
|
||||||
@@ -165,6 +167,8 @@ export {
|
|||||||
parseDiffStat,
|
parseDiffStat,
|
||||||
extractFileScope,
|
extractFileScope,
|
||||||
validateDiffScope,
|
validateDiffScope,
|
||||||
|
assertSquashOverlapsFileScope,
|
||||||
|
FileScopeViolationError,
|
||||||
shouldSyncDependenciesForMerge,
|
shouldSyncDependenciesForMerge,
|
||||||
summarizeVerificationOutput,
|
summarizeVerificationOutput,
|
||||||
inferDefaultTestCommand,
|
inferDefaultTestCommand,
|
||||||
@@ -211,6 +215,7 @@ export function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Tas
|
|||||||
updateSettings: vi.fn().mockResolvedValue({}),
|
updateSettings: vi.fn().mockResolvedValue({}),
|
||||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||||
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
getActiveMergingTask: vi.fn().mockReturnValue(null),
|
||||||
|
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
|
||||||
|
|||||||
@@ -3415,6 +3415,15 @@ export async function commitOrAmendMergeWithFixes(
|
|||||||
// This is the phantom-merge fix: previously the code blindly amended
|
// This is the phantom-merge fix: previously the code blindly amended
|
||||||
// HEAD (the previous task's commit), silently dropping the current
|
// HEAD (the previous task's commit), silently dropping the current
|
||||||
// task's branch and inheriting the prior task's stats.
|
// task's branch and inheriting the prior task's stats.
|
||||||
|
if (store) {
|
||||||
|
await enforceSquashFileScopeInvariant({
|
||||||
|
store,
|
||||||
|
taskId,
|
||||||
|
rootDir,
|
||||||
|
task: await store.getTask(taskId),
|
||||||
|
resetLabel: "file-scope invariant violation",
|
||||||
|
});
|
||||||
|
}
|
||||||
await runDiffVolumeGate({
|
await runDiffVolumeGate({
|
||||||
rootDir,
|
rootDir,
|
||||||
branch,
|
branch,
|
||||||
@@ -3448,6 +3457,15 @@ export async function commitOrAmendMergeWithFixes(
|
|||||||
// HEAD moved — AI agent committed already. Amend with deterministic
|
// HEAD moved — AI agent committed already. Amend with deterministic
|
||||||
// message + any new staged fixes folded in. `--amend -m` replaces both
|
// message + any new staged fixes folded in. `--amend -m` replaces both
|
||||||
// the message and includes any newly-staged content.
|
// the message and includes any newly-staged content.
|
||||||
|
if (store) {
|
||||||
|
await enforceSquashFileScopeInvariant({
|
||||||
|
store,
|
||||||
|
taskId,
|
||||||
|
rootDir,
|
||||||
|
task: await store.getTask(taskId),
|
||||||
|
resetLabel: "file-scope invariant violation",
|
||||||
|
});
|
||||||
|
}
|
||||||
await runDiffVolumeGate({
|
await runDiffVolumeGate({
|
||||||
rootDir,
|
rootDir,
|
||||||
branch,
|
branch,
|
||||||
@@ -3477,7 +3495,7 @@ export async function commitOrAmendMergeWithFixes(
|
|||||||
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
|
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
|
||||||
return { ok: true, reason: "completed" };
|
return { ok: true, reason: "completed" };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof DiffVolumeRegressionError) {
|
if (err instanceof DiffVolumeRegressionError || err instanceof FileScopeViolationError) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
@@ -3582,6 +3600,104 @@ function matchesScope(filePath: string, scopePatterns: string[]): boolean {
|
|||||||
* When `strict` is true, throws an error on scope violations instead of
|
* When `strict` is true, throws an error on scope violations instead of
|
||||||
* just returning warnings (hard guardrail that blocks merge).
|
* just returning warnings (hard guardrail that blocks merge).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export class FileScopeViolationError extends Error {
|
||||||
|
taskId: string;
|
||||||
|
stagedFiles: string[];
|
||||||
|
declaredScope: string[];
|
||||||
|
|
||||||
|
constructor(taskId: string, stagedFiles: string[], declaredScope: string[]) {
|
||||||
|
const stagedList = stagedFiles.length > 0 ? stagedFiles.join(", ") : "<none outside .changeset/>";
|
||||||
|
const scopeList = declaredScope.join(", ");
|
||||||
|
super(
|
||||||
|
`File-scope invariant violation for ${taskId}: staged files [${stagedList}] have zero overlap with declared File Scope [${scopeList}]. Refile genuinely out-of-scope work as a follow-up task via fn_task_create before retrying this merge.`,
|
||||||
|
);
|
||||||
|
this.name = "FileScopeViolationError";
|
||||||
|
this.taskId = taskId;
|
||||||
|
this.stagedFiles = stagedFiles;
|
||||||
|
this.declaredScope = declaredScope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertSquashOverlapsFileScope(params: {
|
||||||
|
store: TaskStore;
|
||||||
|
taskId: string;
|
||||||
|
rootDir: string;
|
||||||
|
task: Task;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { store, taskId, rootDir, task } = params;
|
||||||
|
|
||||||
|
if (task.scopeOverride === true) {
|
||||||
|
const reasonSuffix = task.scopeOverrideReason?.trim()
|
||||||
|
? ` — reason: ${task.scopeOverrideReason.trim()}`
|
||||||
|
: "";
|
||||||
|
await store.appendAgentLog(
|
||||||
|
taskId,
|
||||||
|
`file-scope invariant bypassed via scopeOverride${reasonSuffix}`,
|
||||||
|
"text",
|
||||||
|
undefined,
|
||||||
|
"merger",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof (store as Partial<TaskStore>).parseFileScopeFromPrompt !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredScope = await store.parseFileScopeFromPrompt(taskId);
|
||||||
|
if (declaredScope.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await execAsync("git diff --cached --name-only", {
|
||||||
|
cwd: rootDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
const stagedFiles = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||||
|
const scopedStagedFiles = stagedFiles.filter((file) => !file.startsWith(".changeset/"));
|
||||||
|
const hasOverlap = scopedStagedFiles.some((file) => matchesScope(file, declaredScope));
|
||||||
|
if (!hasOverlap) {
|
||||||
|
throw new FileScopeViolationError(taskId, stagedFiles, declaredScope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileScopeViolationAgentLog(error: FileScopeViolationError): string {
|
||||||
|
const stagedFiles = error.stagedFiles.length > 0 ? error.stagedFiles.join("\n") : "<none>";
|
||||||
|
return [
|
||||||
|
`taskId: ${error.taskId}`,
|
||||||
|
"declaredScope:",
|
||||||
|
...error.declaredScope.map((entry) => `- ${entry}`),
|
||||||
|
"stagedFiles:",
|
||||||
|
...stagedFiles.split("\n").map((entry) => `- ${entry}`),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceSquashFileScopeInvariant(params: {
|
||||||
|
store: TaskStore;
|
||||||
|
taskId: string;
|
||||||
|
rootDir: string;
|
||||||
|
task: Task;
|
||||||
|
resetLabel: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await assertSquashOverlapsFileScope(params);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!(error instanceof FileScopeViolationError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await params.store.appendAgentLog(
|
||||||
|
params.taskId,
|
||||||
|
error.message,
|
||||||
|
"tool_error",
|
||||||
|
formatFileScopeViolationAgentLog(error),
|
||||||
|
"merger",
|
||||||
|
);
|
||||||
|
resetMergeWithWarn(params.rootDir, params.taskId, params.resetLabel);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateDiffScope(
|
export async function validateDiffScope(
|
||||||
store: TaskStore,
|
store: TaskStore,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
@@ -6106,7 +6222,11 @@ export async function aiMergeTask(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof DiffVolumeRegressionError || error?.name === "DiffVolumeRegressionError") {
|
if (
|
||||||
|
error instanceof DiffVolumeRegressionError
|
||||||
|
|| error?.name === "DiffVolumeRegressionError"
|
||||||
|
|| error?.name === "FileScopeViolationError"
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7187,6 +7307,13 @@ export async function executeMergeAttempt(
|
|||||||
aiSummary: safeBody,
|
aiSummary: safeBody,
|
||||||
aiSubject,
|
aiSubject,
|
||||||
});
|
});
|
||||||
|
await enforceSquashFileScopeInvariant({
|
||||||
|
store,
|
||||||
|
taskId,
|
||||||
|
rootDir,
|
||||||
|
task: await store.getTask(taskId),
|
||||||
|
resetLabel: "file-scope invariant violation",
|
||||||
|
});
|
||||||
await runDiffVolumeGate({
|
await runDiffVolumeGate({
|
||||||
rootDir,
|
rootDir,
|
||||||
branch,
|
branch,
|
||||||
@@ -7317,6 +7444,13 @@ export async function executeMergeAttempt(
|
|||||||
// Spawn AI agent
|
// Spawn AI agent
|
||||||
throwIfAborted(options.signal, taskId);
|
throwIfAborted(options.signal, taskId);
|
||||||
aiTracker.aiWasInvoked = true; // Track that AI was invoked
|
aiTracker.aiWasInvoked = true; // Track that AI was invoked
|
||||||
|
await enforceSquashFileScopeInvariant({
|
||||||
|
store,
|
||||||
|
taskId,
|
||||||
|
rootDir,
|
||||||
|
task: await store.getTask(taskId),
|
||||||
|
resetLabel: "file-scope invariant violation",
|
||||||
|
});
|
||||||
const agentResult = await runAiAgentForCommit({
|
const agentResult = await runAiAgentForCommit({
|
||||||
store,
|
store,
|
||||||
rootDir,
|
rootDir,
|
||||||
@@ -7444,7 +7578,11 @@ export async function executeMergeAttempt(
|
|||||||
// and trip the phantom-merge guard even though the task's content is
|
// and trip the phantom-merge guard even though the task's content is
|
||||||
// already on HEAD. Retrying with auto-conflict-resolution can't help a
|
// already on HEAD. Retrying with auto-conflict-resolution can't help a
|
||||||
// verification failure anyway — there are no conflicts to resolve.
|
// verification failure anyway — there are no conflicts to resolve.
|
||||||
if (error?.name === "VerificationError" || error?.name === "DiffVolumeRegressionError") {
|
if (
|
||||||
|
error?.name === "VerificationError"
|
||||||
|
|| error?.name === "DiffVolumeRegressionError"
|
||||||
|
|| error?.name === "FileScopeViolationError"
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7492,7 +7630,7 @@ export async function attemptWithSideStrategy(
|
|||||||
|
|
||||||
return finalizeSideStrategyAttempt(params, side, aiTracker);
|
return finalizeSideStrategyAttempt(params, side, aiTracker);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError")) {
|
if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError" || error.name === "FileScopeViolationError")) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
|
mergerLog.error(`${taskId}: -X ${side} merge failed: ${error}`);
|
||||||
@@ -7533,7 +7671,7 @@ async function attemptWithMixedSideStrategy(
|
|||||||
|
|
||||||
return finalizeSideStrategyAttempt(params, strategy.defaultSide, aiTracker);
|
return finalizeSideStrategyAttempt(params, strategy.defaultSide, aiTracker);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError")) {
|
if (error instanceof Error && (error.name === "MergeAbortedError" || error.name === "DiffVolumeRegressionError" || error.name === "FileScopeViolationError")) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
mergerLog.error(`${taskId}: overlap-aware merge failed: ${error}`);
|
mergerLog.error(`${taskId}: overlap-aware merge failed: ${error}`);
|
||||||
@@ -7593,6 +7731,13 @@ async function finalizeSideStrategyAttempt(
|
|||||||
aiSummary: aiSummary?.trim().length ? aiSummary : safeBody,
|
aiSummary: aiSummary?.trim().length ? aiSummary : safeBody,
|
||||||
aiSubject,
|
aiSubject,
|
||||||
});
|
});
|
||||||
|
await enforceSquashFileScopeInvariant({
|
||||||
|
store,
|
||||||
|
taskId,
|
||||||
|
rootDir,
|
||||||
|
task: await store.getTask(taskId),
|
||||||
|
resetLabel: "file-scope invariant violation",
|
||||||
|
});
|
||||||
await runDiffVolumeGate({
|
await runDiffVolumeGate({
|
||||||
rootDir,
|
rootDir,
|
||||||
branch,
|
branch,
|
||||||
|
|||||||
Reference in New Issue
Block a user