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:
committed by
gsxdsm
parent
bc959c6ca4
commit
f6f38676b4
8
.changeset/FN-5226-scope-auto-widen.md
Normal file
8
.changeset/FN-5226-scope-auto-widen.md
Normal 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.
|
||||
@@ -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 1–3 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 1–3 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).
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -584,7 +584,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(89);
|
||||
expect(db.getSchemaVersion()).toBe(90);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 '[]'");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
219
packages/engine/src/__tests__/merger-scope-auto-widen.test.ts
Normal file
219
packages/engine/src/__tests__/merger-scope-auto-widen.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
239
packages/engine/src/merger-scope-auto-widen.ts
Normal file
239
packages/engine/src/merger-scope-auto-widen.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user