feat(FN-5226): add scope auto-widen layer 2.5 to merger for attribution-bas

Merges the Layer 2.5 scope-auto-widen feature (FN-5226) into the merger: a new evaluator module that automatically widens a task's declared file scope based on git attribution prior to the existing scope partition gate, wired into `merger.ts` with full audit taxonomy, persisted task metadata, and re

Fusion-Task-Id: FN-5226
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 16:29:47 -07:00
committed by gsxdsm
parent bc959c6ca4
commit f6f38676b4
21 changed files with 917 additions and 50 deletions

View File

@@ -0,0 +1,8 @@
---
"@runfusion/fusion": patch
---
Merger Layer 2.5: auto-widen `## File Scope` for files whose branch-side commits
are exclusively attributed to the current task before the FN-4956 scope
partition strips them. Emits `merge:scope:auto-widen` run-audit events.
Fail-closed against foreign commits, cross-task scope claims, and ignored paths.

View File

@@ -311,8 +311,9 @@ Hard-won rules (FN-2370 silently reverted three commits' worth of work):
7. **Pre-commit diff-volume gate.** Before writing an auto-resolved squash commit, the merger compares each file's staged squash delta against branch net delta vs merge-base. Non-allowlisted files losing too much branch volume block the merge in `in-review`. Guard against FN-3936-style silent drops.
8. **Smart-prefer-main overlap guard.** When `mergeConflictStrategy="smart-prefer-main"`, recent main commits (30-commit lookback) overlapping branch-modified files flip to prefer-branch by default (`mergeStrategyOverlapBehavior="flip-to-prefer-branch"`).
9. **Layer 3 scope partition for AI arbitration (FN-4956).** Before handing conflicted files to the Layer 3 AI arbiter, merger partitions conflicts against declared task File Scope. Out-of-scope conflicts are resolved to main (`--ours`) and unstaged so they cannot enter the squash; only in-scope conflicts reach AI. `task.scopeOverride=true` bypasses this partition. Run-audit emits `merge:layer3:foreign-file-skipped` (skip path) or `merge:layer3:scope-override-bypass` (override path).
10. **Auto-prerebase on hot-file/threshold divergence (FN-4958).** Before Stage 1 remote rebase, merger may prerebase the task branch onto local main when hot-file overlap or divergence threshold triggers (`packages/engine/src/merger-auto-prerebase.ts`). Failures are fail-soft (`merge:auto-prerebase:failed`) and fall through to the existing Stage 1/2/Layer 13 cascade; worktrunk-enabled paths defer this layer.
11. **Integration branch advance is ref-only (FN-5350).** After the task worktree squash succeeds, the merger advances `refs/heads/<integration-branch>` via `git update-ref refs/heads/<integration> <new-sha> <expected-current-sha>` against the task-worktree git root, never via `git checkout <integration> && git merge --ff-only`. Compare-and-swap (`expected-current-sha`) preserves the concurrent-advance rule: if integration moved between detach and advance, `update-ref` refuses, the merger throws `IntegrationBranchConcurrentAdvanceError`, the task parks in `in-review` (`status: "failed"`), and upstream re-rebase machinery (FN-4500 / FN-5083 / standard re-execution) recovers on the next pass. Dirty + untracked files in the user's checked-out integration-branch worktree at `projectRootDir` are never touched and never block a merge. On successful advance, the merger logs `<integration> advanced to <sha> via update-ref; your checked-out worktree at <projectRootDir> is now behind` — informational, not an error.
10. **Layer 2.5 scope auto-widen before partition (FN-5226).** When Layer 3 sees out-of-scope conflict files and `scopeOverride !== true`, merger first runs a fail-closed scope auto-widen pass. A file is widened only when every branch-side touching commit is attributed to the current task, no other active non-terminal non-deleted task declares that exact path in `## File Scope`, and the path is neither `.fusion/*` nor gitignored. Successful widens are persisted by appending ``- `<path>` <!-- scopeAutoWiden FN-XXXX -->`` inside `## File Scope`, logged to agent logs, and audited via `merge:scope:auto-widen` (`{ taskId, file, attribution, commits }`). Any failed check (or persist failure) falls through to the existing strip-to-main path.
11. **Auto-prerebase on hot-file/threshold divergence (FN-4958).** Before Stage 1 remote rebase, merger may prerebase the task branch onto local main when hot-file overlap or divergence threshold triggers (`packages/engine/src/merger-auto-prerebase.ts`). Failures are fail-soft (`merge:auto-prerebase:failed`) and fall through to the existing Stage 1/2/Layer 13 cascade; worktrunk-enabled paths defer this layer.
12. **Integration branch advance is ref-only (FN-5350).** After the task worktree squash succeeds, the merger advances `refs/heads/<integration-branch>` via `git update-ref refs/heads/<integration> <new-sha> <expected-current-sha>` against the task-worktree git root, never via `git checkout <integration> && git merge --ff-only`. Compare-and-swap (`expected-current-sha`) preserves the concurrent-advance rule: if integration moved between detach and advance, `update-ref` refuses, the merger throws `IntegrationBranchConcurrentAdvanceError`, the task parks in `in-review` (`status: "failed"`), and upstream re-rebase machinery (FN-4500 / FN-5083 / standard re-execution) recovers on the next pass. Dirty + untracked files in the user's checked-out integration-branch worktree at `projectRootDir` are never touched and never block a merge. On successful advance, the merger logs `<integration> advanced to <sha> via update-ref; your checked-out worktree at <projectRootDir> is now behind` — informational, not an error.
Audit verification surface for FN-5348/FN-5349/FN-5350 invariants: `merge:integration-worktree-state` (captures integration checkout/dirty state and selected integration mode before handoff), `merge:cwd-integration-fallback-refused` (records terminal park when reuse handoff refusal cannot be recovered), and `merge:integration-ref-advance` (records every integration ref advance attempt outcome with resolved branch/ref metadata).

View File

@@ -52,7 +52,7 @@ describe("Database.init() schema compatibility performance", () => {
const pragmaTableInfoCalls = prepareSpy.mock.calls.filter(([sql]) => sql.includes("PRAGMA table_info("));
// Current-schema re-init may probe tasks metadata a few times via legacy
// migration guards; the fingerprint hit should still prevent broad sweeps.
expect(pragmaTableInfoCalls.length).toBeLessThanOrEqual(4);
expect(pragmaTableInfoCalls.length).toBeLessThanOrEqual(5);
} finally {
db.close();
}

View File

@@ -715,7 +715,7 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
db.close();
});
@@ -748,7 +748,7 @@ describe("schema migration", () => {
{ id: "WS-001", mode: "prompt", gateMode: "advisory" },
{ id: "WS-002", mode: "script", gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
db.close();
});
@@ -798,7 +798,7 @@ describe("schema migration", () => {
reviewerContextRetryCount: 0,
reviewerFallbackRetryCount: 0,
});
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
db.close();
});
@@ -827,7 +827,7 @@ describe("schema migration", () => {
const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("acceptanceCriteria");
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
db.close();
});
@@ -862,7 +862,7 @@ describe("schema migration", () => {
{ id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" },
{ id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" },
]);
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
db.close();
});

View File

@@ -302,7 +302,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -345,7 +345,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1415,7 +1415,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1440,11 +1440,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
db.close();
});
@@ -1479,7 +1479,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1520,7 +1520,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1592,7 +1592,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1832,7 +1832,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1906,7 +1906,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1930,7 +1930,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2034,7 +2034,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2253,7 +2253,7 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(89);
expect(localDb.getSchemaVersion()).toBe(90);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2564,7 +2564,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2718,7 +2718,7 @@ describe("migration v77 task token budget columns", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(89);
expect(migrated.getSchemaVersion()).toBe(90);
const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const names = new Set(rows.map((row) => row.name));
expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true);
@@ -2764,7 +2764,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(89);
expect(migrated.getSchemaVersion()).toBe(90);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2791,7 +2791,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(89);
expect(fresh.getSchemaVersion()).toBe(90);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(89);
expect(db1.getSchemaVersion()).toBe(90);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(89);
expect(db3.getSchemaVersion()).toBe(90);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(89);
expect(db1.getSchemaVersion()).toBe(90);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(89);
expect(db2.getSchemaVersion()).toBe(90);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(89);
expect(db1.getSchemaVersion()).toBe(90);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -2821,7 +2821,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
});
it("mission_features table has loop state columns", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
});
});
});

View File

@@ -42,7 +42,7 @@ describe("secrets schema migrations", () => {
const version = db
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(version.value).toBe("89");
expect(version.value).toBe("90");
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
@@ -105,7 +105,7 @@ describe("secrets schema migrations", () => {
const version = db
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(version.value).toBe("89");
expect(version.value).toBe("90");
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
@@ -155,7 +155,7 @@ describe("secrets schema migrations", () => {
.prepare("SELECT value FROM __meta WHERE key = 'schemaVersion'")
.get() as { value: string };
expect(projectVersion.value).toBe("89");
expect(projectVersion.value).toBe("90");
expect(centralVersion.value).toBe("13");
} finally {
projectDb.close();

View File

@@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => {
expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]),
);
expect(store.getDatabase().getSchemaVersion()).toBe(89);
expect(store.getDatabase().getSchemaVersion()).toBe(90);
});
it("migrates a legacy v88 database and preserves task rows", async () => {

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(89);
expect(db.getSchemaVersion()).toBe(90);
const index = db
.prepare(

View File

@@ -120,7 +120,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 89;
const SCHEMA_VERSION = 90;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -268,6 +268,7 @@ CREATE TABLE IF NOT EXISTS tasks (
sliceId TEXT,
scopeOverride INTEGER,
scopeOverrideReason TEXT,
scopeAutoWiden TEXT DEFAULT '[]',
assignedAgentId TEXT,
pausedByAgentId TEXT,
assigneeUserId TEXT,
@@ -1687,6 +1688,7 @@ export class Database {
this.addColumnIfMissing("tasks", "review", "TEXT");
this.addColumnIfMissing("tasks", "userPaused", "INTEGER DEFAULT 0");
this.addColumnIfMissing("tasks", "pausedReason", "TEXT");
this.addColumnIfMissing("tasks", "scopeAutoWiden", "TEXT DEFAULT '[]'");
}
if (version >= SCHEMA_VERSION) return;
@@ -3526,6 +3528,12 @@ export class Database {
});
}
if (version < 90) {
this.applyMigration(90, () => {
this.addColumnIfMissing("tasks", "scopeAutoWiden", "TEXT DEFAULT '[]'");
});
}
}
/**

View File

@@ -152,6 +152,7 @@ interface TaskRow {
sliceId: string | null;
scopeOverride: number | null;
scopeOverrideReason: string | null;
scopeAutoWiden: string | null;
assignedAgentId: string | null;
pausedByAgentId: string | null;
assigneeUserId: string | null;
@@ -1354,6 +1355,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
baseCommitSha: row.baseCommitSha || undefined,
scopeOverride: row.scopeOverride ? true : undefined,
scopeOverrideReason: row.scopeOverrideReason || undefined,
scopeAutoWiden: fromJson<string[]>(row.scopeAutoWiden) ?? [],
modelPresetId: row.modelPresetId || undefined,
modelProvider: row.modelProvider || undefined,
modelId: row.modelId || undefined,
@@ -1736,7 +1738,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt",
// `log` is fetched in slim mode so the server can aggregate
@@ -1785,7 +1787,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"dependencies", "steps", "attachments", "steeringComments",
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
"checkedOutBy", "checkedOutAt", "checkoutNodeId", "checkoutRunId", "checkoutLeaseRenewedAt", "checkoutLeaseEpoch", "deletedAt",
];
@@ -1903,6 +1905,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.sliceId ?? null,
task.scopeOverride ? 1 : null,
task.scopeOverrideReason ?? null,
toJson(task.scopeAutoWiden || []),
task.assignedAgentId ?? null,
task.pausedByAgentId ?? null,
task.assigneeUserId ?? null,
@@ -1945,7 +1948,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies, steps, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, noCommitsExpected, 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, deletedAt
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt
) VALUES (${placeholders})
`).run(...values);
this.db.bumpLastModified();
@@ -1972,7 +1975,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
dependencies, steps, log, attachments, steeringComments,
comments, review, reviewState, workflowStepResults, prInfo, prInfos, issueInfo, githubTracking,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, noCommitsExpected, 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, deletedAt
mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, scopeOverride, scopeOverrideReason, scopeAutoWiden, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch, deletedAt
) VALUES (${placeholders})
ON CONFLICT(id) DO UPDATE SET
lineageId = excluded.lineageId,
@@ -2063,6 +2066,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sliceId = excluded.sliceId,
scopeOverride = excluded.scopeOverride,
scopeOverrideReason = excluded.scopeOverrideReason,
scopeAutoWiden = excluded.scopeAutoWiden,
assignedAgentId = excluded.assignedAgentId,
pausedByAgentId = excluded.pausedByAgentId,
assigneeUserId = excluded.assigneeUserId,
@@ -5082,7 +5086,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
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; 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; 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; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: 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; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
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; 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; 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; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: 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; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -5302,6 +5306,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.scopeOverrideReason !== undefined) {
task.scopeOverrideReason = updates.scopeOverrideReason;
}
if (updates.scopeAutoWiden === null) {
task.scopeAutoWiden = undefined;
} else if (updates.scopeAutoWiden !== undefined) {
task.scopeAutoWiden = [...updates.scopeAutoWiden];
}
if (updates.nodeId === null) {
task.nodeId = undefined;
} else if (updates.nodeId !== undefined) {
@@ -9728,6 +9737,10 @@ ${stepsSection}`;
return this.tasksDir;
}
getTaskDir(id: string): string {
return this.taskDir(id);
}
/** Expose the shared Database instance for co-located stores (e.g. AiSessionStore). */
getDatabase(): Database {
return this.db;

View File

@@ -1642,6 +1642,8 @@ export interface Task {
scopeOverride?: boolean;
/** Optional justification for bypassing the squash file-scope invariant. */
scopeOverrideReason?: string;
/** Append-only list of file paths auto-widened into `## File Scope` by merger safety checks. */
scopeAutoWiden?: string[];
/** Mission ID this task is linked to (for mission hierarchy) */
missionId?: string;
/** Slice ID this task is linked to (for mission hierarchy) */
@@ -1976,6 +1978,8 @@ export interface TaskCreateInput {
scopeOverride?: boolean;
/** Optional justification for bypassing the squash file-scope invariant. */
scopeOverrideReason?: string;
/** Append-only list of file paths auto-widened into `## File Scope` by merger safety checks. */
scopeAutoWiden?: string[];
/** Per-task GitHub issue tracking overrides for Fusion-created linked issues. */
githubTracking?: Pick<TaskGithubTracking, "enabled" | "repoOverride">;
/** Review level for task execution — controls review rigor: 0=None, 1=Plan Only, 2=Plan and Code, 3=Full */

View File

@@ -0,0 +1,219 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "@fusion/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden, ScopeAutoWidenPersistError } from "../merger-scope-auto-widen.js";
describe("merger-scope-auto-widen", () => {
const roots: string[] = [];
async function makeRoot() {
const root = await mkdtemp(join(tmpdir(), "fn-5226-"));
roots.push(root);
return root;
}
afterEach(async () => {
while (roots.length > 0) {
await rm(roots.pop()!, { recursive: true, force: true });
}
});
it("widens a clean own-attributed candidate", async () => {
const store = {
listTasks: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn(),
} as any;
const exec = vi.fn()
.mockRejectedValueOnce(new Error("not ignored"))
.mockResolvedValueOnce({ stdout: "sha1\x00feat(FN-5226): update\x00\x1e" });
const result = await evaluateScopeAutoWiden({
store,
task: { id: "FN-5226" } as any,
taskId: "FN-5226",
rootDir: "/tmp",
branch: "fusion/fn-5226",
baseRef: "main",
candidateFiles: ["packages/engine/src/merger.ts"],
execAsyncImpl: exec as any,
});
expect(result.widened).toEqual([{
file: "packages/engine/src/merger.ts",
attribution: "subject-prefix",
commits: ["sha1"],
}]);
expect(result.refused).toEqual([]);
});
it("refuses on foreign-attributed commits", async () => {
const store = {
listTasks: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn(),
} as any;
const exec = vi.fn()
.mockRejectedValueOnce(new Error("not ignored"))
.mockResolvedValueOnce({ stdout: "sha1\x00feat(FN-9999): foreign\x00\x1e" })
.mockRejectedValueOnce(new Error("not ignored"))
.mockResolvedValueOnce({ stdout: "sha2\x00no token\x00\x1e" });
const result = await evaluateScopeAutoWiden({
store,
task: { id: "FN-5226" } as any,
taskId: "FN-5226",
rootDir: "/tmp",
branch: "fusion/fn-5226",
baseRef: "main",
candidateFiles: ["foreign.ts", "none.ts"],
execAsyncImpl: exec as any,
});
expect(result.widened).toEqual([]);
expect(result.refused).toEqual([
{ file: "foreign.ts", reason: "foreign-commit" },
{ file: "none.ts", reason: "foreign-commit" },
]);
});
it("refuses when git log has no branch-side attribution evidence", async () => {
const store = {
listTasks: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn(),
} as any;
const exec = vi.fn()
.mockRejectedValueOnce(new Error("not ignored"))
.mockResolvedValueOnce({ stdout: "" });
const result = await evaluateScopeAutoWiden({
store,
task: { id: "FN-5226" } as any,
taskId: "FN-5226",
rootDir: "/tmp",
branch: "fusion/fn-5226",
baseRef: "main",
candidateFiles: ["no-log.ts"],
execAsyncImpl: exec as any,
});
expect(result.widened).toEqual([]);
expect(result.refused).toEqual([{ file: "no-log.ts", reason: "no-attribution" }]);
});
it("refuses .fusion and gitignored paths", async () => {
const store = {
listTasks: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn(),
} as any;
const exec = vi.fn().mockResolvedValue({ stdout: "" });
const result = await evaluateScopeAutoWiden({
store,
task: { id: "FN-5226" } as any,
taskId: "FN-5226",
rootDir: "/tmp",
branch: "fusion/fn-5226",
baseRef: "main",
candidateFiles: [".fusion/tasks/FN-1/notes.txt", "ignored.log"],
execAsyncImpl: exec as any,
});
expect(result.widened).toEqual([]);
expect(result.refused).toEqual([
{ file: ".fusion/tasks/FN-1/notes.txt", reason: "ignored-path" },
{ file: "ignored.log", reason: "ignored-path" },
]);
});
it("refuses when another active task claims the path (including glob scopes) and ignores done/archived tasks", async () => {
const store = {
listTasks: vi.fn().mockResolvedValue([
{ id: "FN-100", column: "in-progress", deletedAt: null },
{ id: "FN-101", column: "done", deletedAt: null },
{ id: "FN-102", column: "archived", deletedAt: null },
]),
parseFileScopeFromPrompt: vi.fn(async (taskId: string) => {
if (taskId === "FN-100") return ["claimed.ts", "packages/engine/src/**/*.ts"];
return ["other.ts"];
}),
} as any;
const exec = vi.fn().mockRejectedValue(new Error("not ignored"));
const result = await evaluateScopeAutoWiden({
store,
task: { id: "FN-5226" } as any,
taskId: "FN-5226",
rootDir: "/tmp",
branch: "fusion/fn-5226",
baseRef: "main",
candidateFiles: ["packages/engine/src/utils/foo.ts"],
execAsyncImpl: exec as any,
});
expect(result.widened).toEqual([]);
expect(result.refused).toEqual([{ file: "packages/engine/src/utils/foo.ts", reason: "claimed-by-other-task" }]);
});
it("appends scope markers idempotently and parseFileScopeFromPrompt round-trips", async () => {
const root = await makeRoot();
const taskId = "FN-5226";
const taskDir = join(root, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), `# Prompt\n\n## File Scope\n\n<!-- scopeOverride manual -->\n- \`packages/engine/src/merger.ts\`\n\n## Steps\n- one\n`, "utf-8");
const fakeStore = { getTaskDir: (id: string) => join(root, ".fusion", "tasks", id) } as any;
const addedFirst = await appendAutoWidenedScopeToPrompt({ store: fakeStore, taskId, files: ["AGENTS.md", "packages/engine/src/merger.ts"] });
const addedSecond = await appendAutoWidenedScopeToPrompt({ store: fakeStore, taskId, files: ["AGENTS.md"] });
expect(addedFirst).toEqual(["AGENTS.md"]);
expect(addedSecond).toEqual([]);
const prompt = await readFile(join(taskDir, "PROMPT.md"), "utf-8");
expect(prompt).toContain("<!-- scopeOverride manual -->");
expect(prompt).toContain("- `AGENTS.md` <!-- scopeAutoWiden FN-5226 -->");
expect(prompt.match(/scopeAutoWiden FN-5226/g)?.length ?? 0).toBe(1);
const store = new TaskStore(root, join(root, ".fusion-global-settings"), { inMemoryDb: true });
const parsed = await store.parseFileScopeFromPrompt(taskId);
expect(parsed).toContain("AGENTS.md");
expect(parsed).toContain("packages/engine/src/merger.ts");
});
it("accepts trailer attribution with multi-line commit body", async () => {
const store = {
listTasks: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn(),
} as any;
const exec = vi.fn()
.mockRejectedValueOnce(new Error("not ignored"))
.mockResolvedValueOnce({ stdout: "sha3\x00chore: detailed message\x00Body line\n\nFusion-Task-Id: FN-5226\x1e" });
const result = await evaluateScopeAutoWiden({
store,
task: { id: "FN-5226" } as any,
taskId: "FN-5226",
rootDir: "/tmp",
branch: "fusion/fn-5226",
baseRef: "main",
candidateFiles: ["multiline.ts"],
execAsyncImpl: exec as any,
});
expect(result.widened).toEqual([{ file: "multiline.ts", attribution: "trailer", commits: ["sha3"] }]);
expect(result.refused).toEqual([]);
});
it("throws when File Scope section is missing", async () => {
const root = await makeRoot();
const taskId = "FN-5226";
const taskDir = join(root, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# Prompt\n\n## Steps\n- one\n", "utf-8");
const fakeStore = { getTaskDir: (id: string) => join(root, ".fusion", "tasks", id) } as any;
await expect(appendAutoWidenedScopeToPrompt({ store: fakeStore, taskId, files: ["AGENTS.md"] })).rejects.toBeInstanceOf(ScopeAutoWidenPersistError);
});
});

View File

@@ -0,0 +1,286 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { execSync, spawnSync } from "node:child_process";
import { TaskStore } from "@fusion/core";
import { applyLayer3ConflictScopePartition, getConflictedFiles } from "../../merger.js";
import { checkDiffVolume, DiffVolumeRegressionError } from "../../merger-diff-volume-gate.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(cwd: string, cmd: string): string {
return execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function promptWithScope(scope: string[]): string {
return `# Task\n\n## File Scope\n${scope.map((entry) => `- \`${entry}\``).join("\n")}\n\n## Steps\n- x\n`;
}
async function writeText(rootDir: string, file: string, content: string) {
const absolute = join(rootDir, file);
await mkdir(dirname(absolute), { recursive: true });
await writeFile(absolute, content, "utf-8");
}
async function setupScenario(options: {
taskId?: string;
targetFile: string;
declaredScope?: string[];
branchCommitMessages: Array<{ subject: string; body?: string; content: string }>;
mainContent: string;
scopeOverride?: boolean;
otherTaskScope?: string[];
gitignore?: string;
baseContent?: string;
}) {
const taskId = options.taskId ?? "FN-5226";
const rootDir = await mkdtemp(join(tmpdir(), "fn-5226-ri-"));
git(rootDir, "git init -b main");
git(rootDir, 'git config user.email "test@example.com"');
git(rootDir, 'git config user.name "Test User"');
await writeText(rootDir, "packages/desktop/src/foo.ts", "export const declared = 'base';\n");
await writeText(rootDir, options.targetFile, options.baseContent ?? "base\n");
git(rootDir, "git add .");
git(rootDir, "git commit -m 'chore: base'");
if (options.gitignore) {
await writeText(rootDir, ".gitignore", `${options.gitignore}\n`);
git(rootDir, "git add .gitignore");
git(rootDir, "git commit -m 'chore: ignore generated artifacts'");
}
await mkdir(join(rootDir, ".fusion"), { recursive: true });
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
await store.init();
const createdTask = await store.createTask({
title: "scope auto widen",
description: taskId,
column: "in-review",
branch: `fusion/${taskId.toLowerCase()}`,
baseBranch: "main",
scopeOverride: options.scopeOverride,
prompt: promptWithScope(options.declaredScope ?? ["packages/desktop/src/**"]),
steps: [],
} as any);
const actualTaskId = createdTask.id;
const branchName = `fusion/${actualTaskId.toLowerCase()}`;
await store.updateTask(actualTaskId, { branch: branchName, baseBranch: "main" });
await writeFile(join(rootDir, ".fusion", "tasks", actualTaskId, "PROMPT.md"), promptWithScope(options.declaredScope ?? ["packages/desktop/src/**"]), "utf-8");
if (options.otherTaskScope) {
const otherTask = await store.createTask({
title: "other task",
description: "other task",
column: "todo",
prompt: promptWithScope(options.otherTaskScope),
steps: [],
} as any);
await writeFile(join(rootDir, ".fusion", "tasks", otherTask.id, "PROMPT.md"), promptWithScope(options.otherTaskScope), "utf-8");
}
git(rootDir, `git checkout -b ${branchName}`);
const stageTargetFile = options.gitignore
? `git add -u -- ${JSON.stringify(options.targetFile)}`
: `git add ${JSON.stringify(options.targetFile)}`;
for (const commit of options.branchCommitMessages) {
await writeText(rootDir, options.targetFile, commit.content);
git(rootDir, stageTargetFile);
const subject = commit.subject.replaceAll(taskId, actualTaskId);
const body = commit.body?.replaceAll(taskId, actualTaskId);
const trailer = body ? ` -m ${JSON.stringify(body)}` : "";
git(rootDir, `git commit -m ${JSON.stringify(subject)}${trailer}`);
}
git(rootDir, "git checkout main");
await writeText(rootDir, options.targetFile, options.mainContent);
git(rootDir, stageTargetFile);
git(rootDir, "git commit -m 'feat: main edit'");
git(rootDir, `git merge --squash ${branchName} || true`);
const auditEvents: Array<{ type: string; metadata: any }> = [];
const refreshedTask = await store.getTask(actualTaskId);
const conflicted = await getConflictedFiles(rootDir);
return {
rootDir,
store,
task: refreshedTask,
taskId: actualTaskId,
branchName,
conflicted,
auditEvents,
partition: async () => applyLayer3ConflictScopePartition({
store,
task: refreshedTask,
taskId: actualTaskId,
rootDir,
branch: branchName,
mergeTargetBranch: "main",
conflictFiles: conflicted,
auditor: {
git: async (event: any) => {
auditEvents.push({ type: event.type, metadata: event.metadata });
},
} as any,
}),
cleanup: async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
},
};
}
describeIfGit("reliability interaction: scope auto-widen", () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()!();
}
});
it("clean widen keeps the file in scope, updates prompt, and emits widen audit", async () => {
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [{ subject: "feat(FN-5226): touch foreign file", content: "branch\n" }],
mainContent: "main\n",
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
const prompt = await readFile(join(fixture.rootDir, ".fusion", "tasks", fixture.taskId, "PROMPT.md"), "utf-8");
expect(result.inScopeConflicts).toEqual(["AGENTS.md"]);
expect(result.skippedFiles).toEqual([]);
expect(prompt).toContain(`scopeAutoWiden ${fixture.taskId}`);
expect(fixture.auditEvents.some((event) => (
event.type === "merge:scope:auto-widen" &&
event.metadata.file === "AGENTS.md" &&
event.metadata.attribution === "subject-prefix" &&
Array.isArray(event.metadata.commits) &&
event.metadata.commits.length > 0
))).toBe(true);
expect(fixture.auditEvents.some((event) => event.type === "merge:layer3:foreign-file-skipped" && event.metadata.skippedFiles.includes("AGENTS.md"))).toBe(false);
});
it("rejects widening when a foreign-attributed commit touched the file", async () => {
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [
{ subject: "feat(FN-5226): touch foreign file", content: "branch-1\n" },
{ subject: "feat(FN-7000): foreign touch", body: "Fusion-Task-Id: FN-7000", content: "branch-2\n" },
],
mainContent: "main\n",
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
expect(result.skippedFiles).toEqual(["AGENTS.md"]);
expect(fixture.auditEvents.some((event) => event.type === "merge:scope:auto-widen")).toBe(false);
expect(fixture.auditEvents.some((event) => event.type === "merge:layer3:foreign-file-skipped" && event.metadata.skippedFiles.includes("AGENTS.md"))).toBe(true);
});
it("rejects widening when another active task already claims the file", async () => {
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [{ subject: "feat(FN-5226): touch foreign file", content: "branch\n" }],
mainContent: "main\n",
otherTaskScope: ["AGENTS.md"],
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
expect(result.skippedFiles).toEqual(["AGENTS.md"]);
expect(fixture.auditEvents.some((event) => event.type === "merge:scope:auto-widen")).toBe(false);
});
it("rejects widening for ignored-path guard files", async () => {
const fixture = await setupScenario({
targetFile: ".fusion/tmp.txt",
branchCommitMessages: [{ subject: "feat(FN-5226): touch scratch file", content: "branch\n" }],
mainContent: "main\n",
baseContent: "base\n",
});
cleanups.push(fixture.cleanup);
const result = await applyLayer3ConflictScopePartition({
store: fixture.store,
task: fixture.task,
taskId: fixture.taskId,
rootDir: fixture.rootDir,
branch: fixture.branchName,
mergeTargetBranch: "main",
conflictFiles: [".fusion/tmp.txt"],
auditor: {
git: async (event: any) => {
fixture.auditEvents.push({ type: event.type, metadata: event.metadata });
},
} as any,
});
expect(result.skippedFiles).toEqual([".fusion/tmp.txt"]);
expect(fixture.auditEvents.some((event) => event.type === "merge:scope:auto-widen")).toBe(false);
expect(fixture.auditEvents.some((event) => event.type === "merge:layer3:foreign-file-skipped")).toBe(true);
});
it("preserves scopeOverride short-circuit", async () => {
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [{ subject: "feat(FN-5226): touch foreign file", content: "branch\n" }],
mainContent: "main\n",
scopeOverride: true,
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
expect(result.viaScopeOverride).toBe(true);
expect(fixture.auditEvents.some((event) => event.type === "merge:scope:auto-widen")).toBe(false);
expect(fixture.auditEvents.some((event) => event.type === "merge:layer3:scope-override-bypass")).toBe(true);
});
it("composes with the diff-volume gate once the widened file is staged", async () => {
const branchContent = Array.from({ length: 70 }, (_, index) => `branch-${index}`).join("\n") + "\n";
const mainContent = Array.from({ length: 3 }, (_, index) => `main-${index}`).join("\n") + "\n";
const fixture = await setupScenario({
targetFile: "AGENTS.md",
branchCommitMessages: [{ subject: "feat(FN-5226): touch foreign file", content: branchContent }],
mainContent,
baseContent: "base\n",
});
cleanups.push(fixture.cleanup);
const result = await fixture.partition();
expect(result.inScopeConflicts).toEqual(["AGENTS.md"]);
await writeFile(join(fixture.rootDir, "AGENTS.md"), branchContent, "utf-8");
git(fixture.rootDir, "git add AGENTS.md");
await expect(checkDiffVolume({
rootDir: fixture.rootDir,
branch: fixture.branchName,
integrationTargetSha: "main",
minLines: 10,
threshold: 0.5,
allowlistGlobs: [],
taskId: fixture.taskId,
})).resolves.toBeUndefined();
await writeFile(join(fixture.rootDir, "AGENTS.md"), mainContent, "utf-8");
git(fixture.rootDir, "git add AGENTS.md");
await expect(checkDiffVolume({
rootDir: fixture.rootDir,
branch: fixture.branchName,
integrationTargetSha: "main",
minLines: 10,
threshold: 0.5,
allowlistGlobs: [],
taskId: fixture.taskId,
})).rejects.toBeInstanceOf(DiffVolumeRegressionError);
});
});

View File

@@ -87,4 +87,29 @@ describe("run-audit provisioning mutation types", () => {
"merge:integration-ref-advance",
]);
});
it("records merge:scope:auto-widen git events", async () => {
const store = new AuditStoreStub();
const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-5226" });
await auditor.git({
type: "merge:scope:auto-widen",
target: "fusion/fn-5226",
metadata: {
taskId: "FN-5226",
file: "AGENTS.md",
attribution: "subject-prefix",
commits: ["abc123"],
},
});
expect(store.events).toHaveLength(1);
expect(store.events[0]?.mutationType).toBe("merge:scope:auto-widen");
expect(store.events[0]?.metadata).toEqual({
taskId: "FN-5226",
file: "AGENTS.md",
attribution: "subject-prefix",
commits: ["abc123"],
});
});
});

View File

@@ -0,0 +1,239 @@
import { exec } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import type { Task, TaskStore } from "@fusion/core";
import { toTaskToken } from "./merger.js";
const execAsync = promisify(exec);
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
type Attribution = "subject-prefix" | "bracketed-prefix" | "trailer";
export type ScopeAutoWidenRefusalReason = "foreign-commit" | "claimed-by-other-task" | "ignored-path" | "no-attribution";
export interface ScopeAutoWidenAccepted {
file: string;
attribution: Attribution;
commits: string[];
}
export interface ScopeAutoWidenRefused {
file: string;
reason: ScopeAutoWidenRefusalReason;
}
export interface ScopeAutoWidenResult {
widened: ScopeAutoWidenAccepted[];
refused: ScopeAutoWidenRefused[];
}
export interface EvaluateScopeAutoWidenParams {
store: Pick<TaskStore, "parseFileScopeFromPrompt"> & Partial<Pick<TaskStore, "listTasks">>;
task: Task;
taskId: string;
rootDir: string;
branch: string;
baseRef: string;
candidateFiles: string[];
execAsyncImpl?: typeof execAsync;
}
export class ScopeAutoWidenPersistError extends Error {
constructor(message: string) {
super(message);
this.name = "ScopeAutoWidenPersistError";
}
}
function quoteArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function attributedBySubjectPrefix(subject: string, taskToken: string): boolean {
const conventional = /^(?:feat|fix|test|chore|docs|refactor|perf|build|ci|style|revert)\s*\(([A-Z]+-\d+)\)!?:/i.exec(subject);
if (conventional?.[1] && toTaskToken(conventional[1]) === taskToken) return true;
const legacyColon = /^\s*([A-Z]+-\d+):/i.exec(subject);
return !!legacyColon?.[1] && toTaskToken(legacyColon[1]) === taskToken;
}
function attributedByBracketedPrefix(subject: string, taskToken: string): boolean {
const bracketed = /^\s*\[([A-Z]+-\d+)\]/i.exec(subject);
return !!bracketed?.[1] && toTaskToken(bracketed[1]) === taskToken;
}
function attributedByTrailer(body: string, taskToken: string): boolean {
const trailerPattern = /(?:^|\n)(?:Fusion-Task-Id|Task-Id):\s*(\S+)\s*(?:\n|$)/gim;
let match: RegExpExecArray | null = null;
let last: RegExpExecArray | null = null;
while (true) {
match = trailerPattern.exec(body);
if (!match) break;
last = match;
}
return !!last?.[1] && toTaskToken(last[1]) === taskToken;
}
function classifyCommitAttribution(subject: string, body: string, taskToken: string): Attribution | null {
if (attributedByTrailer(body, taskToken)) return "trailer";
if (attributedBySubjectPrefix(subject, taskToken)) return "subject-prefix";
if (attributedByBracketedPrefix(subject, taskToken)) return "bracketed-prefix";
return null;
}
async function isGitIgnored(file: string, rootDir: string, execImpl: typeof execAsync): Promise<boolean> {
try {
await execImpl(`git check-ignore -- ${quoteArg(file)}`, {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: GIT_MAX_BUFFER,
});
return true;
} catch {
return false;
}
}
function matchGlob(path: string, glob: string): boolean {
const escaped = glob
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "::DOUBLESTAR::")
.replace(/\*/g, "[^/]*")
.replace(/::DOUBLESTAR::/g, ".*");
return new RegExp(`^${escaped}$`).test(path);
}
function scopeContainsPath(scope: string[], file: string): boolean {
return scope.some((entry) => entry === file || (entry.includes("*") && matchGlob(file, entry)));
}
export async function evaluateScopeAutoWiden(params: EvaluateScopeAutoWidenParams): Promise<ScopeAutoWidenResult> {
const { store, task, taskId, rootDir, branch, baseRef, candidateFiles } = params;
const execImpl = params.execAsyncImpl ?? execAsync;
const widened: ScopeAutoWidenAccepted[] = [];
const refused: ScopeAutoWidenRefused[] = [];
const taskToken = toTaskToken(task.id || taskId);
const allTasks = typeof store.listTasks === "function"
? await store.listTasks({ slim: true, includeArchived: false })
: [];
const activeOtherTasks = allTasks.filter((other) => (
other.id !== taskId &&
other.deletedAt == null &&
other.column !== "done" &&
other.column !== "archived"
));
for (const file of candidateFiles) {
if (file === ".fusion" || file.startsWith(".fusion/")) {
refused.push({ file, reason: "ignored-path" });
continue;
}
if (await isGitIgnored(file, rootDir, execImpl)) {
refused.push({ file, reason: "ignored-path" });
continue;
}
let claimed = false;
for (const otherTask of activeOtherTasks) {
try {
const otherScope = await store.parseFileScopeFromPrompt(otherTask.id);
if (scopeContainsPath(otherScope, file)) {
refused.push({ file, reason: "claimed-by-other-task" });
claimed = true;
break;
}
} catch {
// fail-open per-task parse errors for peer prompts
}
}
if (claimed) continue;
const { stdout } = await execImpl(
`git log ${quoteArg(`${baseRef}..${branch}`)} --format=%H%x00%s%x00%B%x1e -- ${quoteArg(file)}`,
{
cwd: rootDir,
encoding: "utf-8",
maxBuffer: GIT_MAX_BUFFER,
},
);
const records = stdout.split("\x1e").map((entry) => entry.trim()).filter(Boolean);
if (records.length === 0) {
refused.push({ file, reason: "no-attribution" });
continue;
}
const commits: string[] = [];
const attributions: Attribution[] = [];
let isForeign = false;
for (const record of records) {
const [sha = "", subject = "", ...bodyParts] = record.split("\x00");
const body = bodyParts.join("\x00");
const attribution = classifyCommitAttribution(subject, body, taskToken);
if (!attribution) {
isForeign = true;
break;
}
commits.push(sha);
attributions.push(attribution);
}
if (isForeign) {
refused.push({ file, reason: "foreign-commit" });
continue;
}
const attribution = attributions.every((value) => value === attributions[0]) ? attributions[0]! : "trailer";
widened.push({ file, attribution, commits });
}
return { widened, refused };
}
function splitPromptSections(prompt: string): { before: string; section: string; after: string } {
const headingMatch = prompt.match(/^##\s+File Scope\s*$/m);
if (!headingMatch || headingMatch.index == null) {
throw new ScopeAutoWidenPersistError("PROMPT.md missing ## File Scope section");
}
const sectionStart = headingMatch.index + headingMatch[0].length;
const rest = prompt.slice(sectionStart);
const nextHeadingIndex = rest.search(/\n##?\s/);
const sectionEnd = nextHeadingIndex === -1 ? prompt.length : sectionStart + nextHeadingIndex;
return {
before: prompt.slice(0, sectionStart),
section: prompt.slice(sectionStart, sectionEnd),
after: prompt.slice(sectionEnd),
};
}
function parseScopeEntries(section: string): Set<string> {
const tokens = section.match(/`([^`]+)`/g) ?? [];
return new Set(tokens.map((token) => token.slice(1, -1)));
}
export async function appendAutoWidenedScopeToPrompt(params: {
store: Pick<TaskStore, "getTaskDir">;
taskId: string;
files: string[];
}): Promise<string[]> {
const { store, taskId, files } = params;
if (files.length === 0) return [];
const promptPath = join(store.getTaskDir(taskId), "PROMPT.md");
const prompt = await readFile(promptPath, "utf-8");
const { before, section, after } = splitPromptSections(prompt);
const existing = parseScopeEntries(section);
const toAdd = files.filter((file) => !existing.has(file));
if (toAdd.length === 0) return [];
const insertion = toAdd.map((file) => `- \`${file}\` <!-- scopeAutoWiden ${taskId} -->`).join("\n");
const sectionTrimmed = section.trimEnd();
const normalizedSection = sectionTrimmed.length === 0 ? `\n\n${insertion}\n` : `${sectionTrimmed}\n${insertion}\n`;
await writeFile(promptPath, `${before}${normalizedSection}${after}`, "utf-8");
return toAdd;
}

View File

@@ -111,6 +111,7 @@ import {
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { advanceIntegrationBranchRef, IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
import { appendAutoWidenedScopeToPrompt, evaluateScopeAutoWiden } from "./merger-scope-auto-widen.js";
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
export { IntegrationBranchConcurrentAdvanceError } from "./merger-ref-update-advance.js";
@@ -534,7 +535,7 @@ async function findOwnedLandedCommitForTask(rootDir: string, task: Task): Promis
return null;
}
function toTaskToken(value: string): string {
export function toTaskToken(value: string): string {
return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
@@ -2652,6 +2653,7 @@ async function tryRecoverHardFailApply(params: {
taskId,
rootDir,
branch: task.branch || canonicalFusionBranchName(taskId),
mergeTargetBranch: task.baseBranch || "main",
conflictFiles: threeWayConflicted,
auditor: undefined,
});
@@ -3081,6 +3083,7 @@ async function restoreUnrelatedRootDirChanges(
taskId,
rootDir,
branch: task.branch || canonicalFusionBranchName(taskId),
mergeTargetBranch: task.baseBranch || "main",
conflictFiles: conflictedFiles,
auditor: undefined,
});
@@ -4080,10 +4083,11 @@ export async function applyLayer3ConflictScopePartition(params: {
taskId: string;
rootDir: string;
branch: string;
mergeTargetBranch?: string;
conflictFiles: string[];
auditor?: RunAuditor;
}): Promise<{ inScopeConflicts: string[]; skippedFiles: string[]; declaredScope: string[]; viaScopeOverride: boolean }> {
const { store, task, taskId, rootDir, branch, conflictFiles, auditor } = params;
const { store, task, taskId, rootDir, branch, mergeTargetBranch = "main", conflictFiles, auditor } = params;
if (conflictFiles.length === 0 || typeof (store as Partial<TaskStore>).parseFileScopeFromPrompt !== "function") {
return { inScopeConflicts: conflictFiles, skippedFiles: [], declaredScope: [], viaScopeOverride: false };
}
@@ -4114,7 +4118,62 @@ export async function applyLayer3ConflictScopePartition(params: {
return { inScopeConflicts: conflictFiles, skippedFiles: [], declaredScope, viaScopeOverride: false };
}
const { inScope, outOfScope } = partitionConflictsByFileScope({ conflictFiles, declaredScope });
let effectiveDeclaredScope = [...declaredScope];
let outOfScope = conflictFiles.filter((file) => !matchesScope(file, effectiveDeclaredScope));
if (outOfScope.length > 0) {
const scopeAutoWiden = await evaluateScopeAutoWiden({
store,
task,
taskId,
rootDir,
branch,
baseRef: mergeTargetBranch,
candidateFiles: outOfScope,
});
if (scopeAutoWiden.widened.length > 0) {
try {
const widenedFiles = await appendAutoWidenedScopeToPrompt({
store,
taskId,
files: scopeAutoWiden.widened.map((entry) => entry.file),
});
if (widenedFiles.length > 0) {
effectiveDeclaredScope = await store.parseFileScopeFromPrompt(taskId);
const widenedSet = new Set(widenedFiles);
for (const widened of scopeAutoWiden.widened.filter((entry) => widenedSet.has(entry.file))) {
if (auditor) {
await auditor.git({
type: "merge:scope:auto-widen",
target: branch,
metadata: {
taskId,
file: widened.file,
attribution: widened.attribution,
commits: widened.commits,
},
}).catch((error: unknown) => {
mergerLog.warn(`${taskId}: failed to emit merge:scope:auto-widen run_audit event: ${error instanceof Error ? error.message : String(error)}`);
});
}
}
await store.appendAgentLog(
taskId,
`Layer 2.5 auto-widened File Scope: ${widenedFiles.join(", ")}`,
"text",
undefined,
"merger",
);
}
} catch (error) {
mergerLog.warn(`${taskId}: failed to persist Layer 2.5 auto-widened scope, continuing with strip path: ${error instanceof Error ? error.message : String(error)}`);
}
}
outOfScope = outOfScope.filter((file) => !matchesScope(file, effectiveDeclaredScope));
}
const { inScope } = partitionConflictsByFileScope({ conflictFiles, declaredScope: effectiveDeclaredScope });
for (const file of outOfScope) {
// In merge and rebase conflict contexts, `--ours` resolves to the
// integration-target side (main bytes), which we keep for out-of-scope files.
@@ -4142,7 +4201,7 @@ export async function applyLayer3ConflictScopePartition(params: {
metadata: {
taskId,
skippedFiles: outOfScope,
declaredScope,
declaredScope: effectiveDeclaredScope,
inScopeCount: inScope.length,
viaScopeOverride: false,
},
@@ -4152,7 +4211,7 @@ export async function applyLayer3ConflictScopePartition(params: {
}
}
return { inScopeConflicts: inScope, skippedFiles: outOfScope, declaredScope, viaScopeOverride: false };
return { inScopeConflicts: inScope, skippedFiles: outOfScope, declaredScope: effectiveDeclaredScope, viaScopeOverride: false };
}
/**
@@ -8180,6 +8239,7 @@ export async function aiMergeTask(
options,
result,
settings,
mergeTargetBranch: mergeTarget.branch,
testCommand: effectiveTestCommand,
buildCommand: effectiveBuildCommand,
testSource: effectiveTestSource,
@@ -9442,6 +9502,7 @@ interface MergeAttemptParams {
options: MergerOptions;
result: MergeResult;
settings: Settings;
mergeTargetBranch?: string;
testCommand?: string;
buildCommand?: string;
/** Source of the test command: 'explicit' from settings or 'inferred' from project files */
@@ -9568,6 +9629,7 @@ export async function executeMergeAttempt(
taskId,
rootDir,
branch,
mergeTargetBranch: params.mergeTargetBranch ?? "main",
conflictFiles: conflictedFiles,
auditor: params.auditor,
});
@@ -9760,6 +9822,7 @@ export async function executeMergeAttempt(
taskId,
rootDir,
branch,
mergeTargetBranch: params.mergeTargetBranch ?? "main",
conflictFiles: conflictedFiles,
auditor: params.auditor,
});

View File

@@ -156,6 +156,7 @@ export type GitMutationType =
| "merge:auto-prerebase:failed"
| "merge:layer3:foreign-file-skipped"
| "merge:layer3:scope-override-bypass"
| "merge:scope:auto-widen"
| "merge:reuse-handoff-acquired"
| "merge:reuse-handoff-refused"
| "merge:reuse-handoff-released"

View File

@@ -743,8 +743,8 @@ describe("RoadmapStore", () => {
});
describe("schema version", () => {
it("schema version is 89 after init", () => {
expect(db.getSchemaVersion()).toBe(89);
it("schema version is 90 after init", () => {
expect(db.getSchemaVersion()).toBe(90);
});
});