diff --git a/.changeset/fn-6277-legacy-automerge-stamp-cleanup.md b/.changeset/fn-6277-legacy-automerge-stamp-cleanup.md new file mode 100644 index 0000000000..ac70bfbb39 --- /dev/null +++ b/.changeset/fn-6277-legacy-automerge-stamp-cleanup.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Add `autoMergeProvenance` so Fusion can distinguish explicit per-task auto-merge overrides from legacy review-entry stamps. Startup now marks ambiguous legacy in-review `autoMerge: true` rows as `legacy-stamp` without changing behavior, and the operator-visible `reconcileLegacyAutoMergeStamps` action (dry-run by default) can clear those legacy stamps so global auto-merge OFF is respected while genuine user overrides are preserved. diff --git a/docs/architecture.md b/docs/architecture.md index d55093c32c..833a1c454d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1793,7 +1793,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count ..` is > 0, and `git diff --quiet ..` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases. - **Verified no-op/duplicate executor completion (FN-6275)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`) or the task already carries a no-commit contract. The sentinel only relaxes the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Ordinary zero-commit implementation completions without a leading sentinel are still refused. - **In-review branch-binding self-heal (FN-5083)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`. -- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. +- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. - **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-integration-branch` (`cwd-main` remains a deprecated alias normalized to that mode). - **Orphaned execution sweep is observation-only (FN-5337)**: `recoverOrphanedExecutions` only annotates stale in-progress candidates with `task:orphan-detected-no-action` and `[orphan-detected] ... no action (operator-decides)` logs. It must never move `in-progress`/`in-review` backward to `todo` or mutate lease/worktree metadata. Proof-based backward recovery remains exclusively in `recoverInProgressLimbo` (FN-5219), `RestartRecoveryCoordinator`, `recoverMissingWorktreeReviewFailures`, and explicit executor/merger failure paths. Reintroducing lifecycle mutation here requires hard git/session proof gating plus CEO+CTO+PM sign-off. - **Self-owned reclaim resume-limbo escalation (FN-5704)**: `reclaimSelfOwnedBranchConflicts` tracks `resumeLimboCount`, `resumeLimboTipSha`, and `resumeLimboStepSignature` for in-progress reclaim/unpause loops. If reclaim finds no progress (same tip, same step-status signature, and no active-session signal) for `MAX_NO_PROGRESS_RESUME_ATTEMPTS` consecutive sweeps, self-healing escalates by moving the task to `todo` with `preserveWorktree: true`, `preserveProgress: true`, and `preserveResumeState: true` instead of endlessly re-arming resume. Escalation emits `task:resume-limbo-escalated` run-audit metadata (`frozenTipSha`, `idleMs`, `resumeAttemptCount`, `currentStep`) and resets the limbo counter. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 4ae74e22d5..fb287dadc0 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -305,7 +305,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` | `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. | | `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. | | `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. | -| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | +| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | | `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. | | `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). | | `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. | diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 9b77725ae8..3b97bdf49a 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -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(116); + expect(db.getSchemaVersion()).toBe(117); 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(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); 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(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -902,7 +902,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(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -1000,7 +1000,7 @@ describe("schema migration", () => { expect(customFieldsColumn).toBeDefined(); expect(customFieldsColumn?.dflt_value).toBe("'{}'"); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -1038,7 +1038,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(workflow_settings)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idx_workflow_settings_project")).toBe(true); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -1120,7 +1120,7 @@ describe("schema migration", () => { expect(indexNames).toContain("idx_cli_sessions_chatSessionId"); expect(indexNames).toContain("idx_cli_sessions_project_state"); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -1152,7 +1152,7 @@ describe("schema migration", () => { .all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("cliExecutorAdapterId"); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -1162,7 +1162,7 @@ describe("schema migration", () => { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>; expect(tables.map((row) => row.name)).toContain("cli_sessions"); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -1219,20 +1219,20 @@ describe("schema migration", () => { .get() as { migrated_fragment_id: string | null }; expect(stepRow.migrated_fragment_id).toBeNull(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); it("migration 109 is idempotent on re-init", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); // Re-open the same on-disk DB: already at 109, the 109 block must be a no-op. const reopened = new Database(fusionDir); reopened.init(); - expect(reopened.getSchemaVersion()).toBe(116); + expect(reopened.getSchemaVersion()).toBe(117); const workflowColumns = reopened.prepare("PRAGMA table_info(workflows)").all() as Array<{ name: string }>; expect(workflowColumns.filter((c) => c.name === "kind")).toHaveLength(1); const stepColumns = reopened.prepare("PRAGMA table_info(workflow_steps)").all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 5afb455c8b..d661041003 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,15 +1488,15 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); db.close(); }); @@ -1531,7 +1531,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1572,7 +1572,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1644,7 +1644,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1884,7 +1884,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1958,7 +1958,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); 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" }]); @@ -1982,7 +1982,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); 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" }]); @@ -2086,7 +2086,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2305,7 +2305,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(116); + expect(localDb.getSchemaVersion()).toBe(117); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2616,7 +2616,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2770,7 +2770,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(116); + expect(migrated.getSchemaVersion()).toBe(117); 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); @@ -2801,7 +2801,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(116); + expect(fresh.getSchemaVersion()).toBe(117); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2829,7 +2829,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(116); + expect(migrated.getSchemaVersion()).toBe(117); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2855,7 +2855,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(116); + expect(fresh.getSchemaVersion()).toBe(117); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2889,7 +2889,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(116); + expect(migrated.getSchemaVersion()).toBe(117); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2930,7 +2930,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(116); + expect(migrated.getSchemaVersion()).toBe(117); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2957,7 +2957,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(116); + expect(fresh.getSchemaVersion()).toBe(117); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 0f55f5d80f..c18b43ff60 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 467dfa3f79..49fb26341e 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -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(116); + expect(db1.getSchemaVersion()).toBe(117); 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(116); + expect(db3.getSchemaVersion()).toBe(117); // 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(116); + expect(db1.getSchemaVersion()).toBe(117); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(116); + expect(db2.getSchemaVersion()).toBe(117); 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(116); + expect(db1.getSchemaVersion()).toBe(117); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/legacy-automerge-stamp-reconcile.test.ts b/packages/core/src/__tests__/legacy-automerge-stamp-reconcile.test.ts new file mode 100644 index 0000000000..49db519d67 --- /dev/null +++ b/packages/core/src/__tests__/legacy-automerge-stamp-reconcile.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { TaskStore } from "../store.js"; +import { allowsAutoMergeProcessing } from "../task-merge.js"; +import type { Task } from "../types.js"; +import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js"; + +async function moveToReview(store: TaskStore, description: string): Promise { + const task = await store.createTask({ description }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + return store.moveTask(task.id, "in-review"); +} + +async function seedLegacyStamp(store: TaskStore, rootDir: string, description = "legacy stamp"): Promise { + const task = await moveToReview(store, description); + (store as any).db.prepare("UPDATE tasks SET autoMerge = 1, autoMergeProvenance = NULL WHERE id = ?").run(task.id); + const taskJsonPath = join(rootDir, ".fusion", "tasks", task.id, "task.json"); + const diskTask = JSON.parse(await readFile(taskJsonPath, "utf-8")) as Task; + diskTask.autoMerge = true; + delete diskTask.autoMergeProvenance; + await writeFile(taskJsonPath, JSON.stringify(diskTask, null, 2)); + return (await store.getTask(task.id))!; +} + +async function resetLegacyMarker(store: TaskStore): Promise { + (store as any).db.prepare("DELETE FROM __meta WHERE key = 'legacyAutoMergeStampMarkedVersion'").run(); +} + +describe("legacy auto-merge stamp reconciliation", () => { + const harness = createTaskStoreTestHarness(); + let rootDir: string; + let store: TaskStore; + + afterEach(async () => { + await harness.afterEach(); + }); + + async function setupHarness(): Promise { + await harness.beforeEach(); + rootDir = harness.rootDir(); + store = harness.store(); + } + + it("marks ambiguous legacy in-review stamps once without changing autoMerge", async () => { + await setupHarness(); + const legacy = await seedLegacyStamp(store, rootDir); + const user = await moveToReview(store, "user override"); + await store.updateTask(user.id, { autoMerge: true }); + await resetLegacyMarker(store); + + await (store as any).markLegacyAutoMergeStampsOnce(); + + const marked = await store.getTask(legacy.id); + const preserved = await store.getTask(user.id); + expect(marked?.autoMerge).toBe(true); + expect(marked?.autoMergeProvenance).toBe("legacy-stamp"); + expect(preserved?.autoMerge).toBe(true); + expect(preserved?.autoMergeProvenance).toBe("user"); + + const firstAuditCount = store.getRunAuditEvents({ mutationType: "task:auto-merge-legacy-stamp-marked" }).length; + await (store as any).markLegacyAutoMergeStampsOnce(); + expect(store.getRunAuditEvents({ mutationType: "task:auto-merge-legacy-stamp-marked" })).toHaveLength(firstAuditCount); + }); + + it("no-ops on empty and zero-candidate databases while setting the once marker", async () => { + await setupHarness(); + await resetLegacyMarker(store); + + await (store as any).markLegacyAutoMergeStampsOnce(); + + expect(store.getRunAuditEvents({ mutationType: "task:auto-merge-legacy-stamp-marked" })).toHaveLength(0); + const regular = await moveToReview(store, "no override"); + expect(regular.autoMerge).toBeUndefined(); + await (store as any).markLegacyAutoMergeStampsOnce(); + expect((await store.getTask(regular.id))?.autoMergeProvenance).toBeUndefined(); + }); + + it("dry-runs candidates without mutating and apply clears only legacy stamps", async () => { + await setupHarness(); + const legacy = await seedLegacyStamp(store, rootDir); + await resetLegacyMarker(store); + await (store as any).markLegacyAutoMergeStampsOnce(); + + const user = await moveToReview(store, "genuine user true"); + await store.updateTask(user.id, { autoMerge: true }); + + const dryRun = await store.reconcileLegacyAutoMergeStamps(); + expect(dryRun).toEqual([{ taskId: legacy.id, column: "in-review", cleared: false }]); + expect((await store.getTask(legacy.id))?.autoMerge).toBe(true); + expect((await store.getTask(legacy.id))?.autoMergeProvenance).toBe("legacy-stamp"); + + // Original symptom: with global autoMerge off, the legacy value still passes the gate. + expect(allowsAutoMergeProcessing((await store.getTask(legacy.id))!, { autoMerge: false })).toBe(true); + + const applied = await store.reconcileLegacyAutoMergeStamps({ apply: true }); + expect(applied).toEqual([{ taskId: legacy.id, column: "in-review", cleared: true }]); + + const cleared = (await store.getTask(legacy.id))!; + expect(cleared.autoMerge).toBeUndefined(); + expect(cleared.autoMergeProvenance).toBeUndefined(); + expect(allowsAutoMergeProcessing(cleared, { autoMerge: false })).toBe(false); + + const preserved = (await store.getTask(user.id))!; + expect(preserved.autoMerge).toBe(true); + expect(preserved.autoMergeProvenance).toBe("user"); + expect(allowsAutoMergeProcessing(preserved, { autoMerge: false })).toBe(true); + + const clearAudits = store.getRunAuditEvents({ mutationType: "task:auto-merge-legacy-stamp-cleared" }); + expect(clearAudits).toHaveLength(1); + expect(clearAudits[0]?.target).toBe(legacy.id); + }); + + it("round-trips provenance through SQLite and task.json, including absent provenance", async () => { + const diskRoot = makeTmpDir(); + const globalDir = makeTmpDir(); + let diskStore = new TaskStore(diskRoot, globalDir); + await diskStore.init(); + try { + const inherited = await moveToReview(diskStore, "absent provenance"); + const explicit = await moveToReview(diskStore, "explicit provenance"); + await diskStore.updateTask(explicit.id, { autoMerge: true }); + + const explicitJson = JSON.parse(await readFile(join(diskRoot, ".fusion", "tasks", explicit.id, "task.json"), "utf-8")) as Task; + const inheritedJson = JSON.parse(await readFile(join(diskRoot, ".fusion", "tasks", inherited.id, "task.json"), "utf-8")) as Task; + expect(explicitJson.autoMergeProvenance).toBe("user"); + expect(inheritedJson.autoMergeProvenance).toBeUndefined(); + + diskStore.close(); + diskStore = new TaskStore(diskRoot, globalDir); + await diskStore.init(); + + expect((await diskStore.getTask(explicit.id))?.autoMergeProvenance).toBe("user"); + expect((await diskStore.getTask(explicit.id, { activityLogLimit: 50 }))?.autoMergeProvenance).toBe("user"); + expect((await diskStore.getTask(inherited.id))?.autoMergeProvenance).toBeUndefined(); + expect((await diskStore.getTask(inherited.id, { activityLogLimit: 50 }))?.autoMergeProvenance).toBeUndefined(); + } finally { + diskStore.close(); + await rm(diskRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + await rm(globalDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); +}); diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index dd9089dfd3..1d5623594d 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 62e62ac8c8..a78da744cb 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(116); + expect(db.getSchemaVersion()).toBe(117); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 73edb60e08..814b23a1a6 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -583,8 +583,8 @@ describe("Run Audit", () => { expect(indexNames).toContain("idxRunAuditEventsTimestamp"); }); - it("schema version is bumped to 116", () => { - expect(db.getSchemaVersion()).toBe(116); + it("schema version is bumped to 117", () => { + expect(db.getSchemaVersion()).toBe(117); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 3795c5b099..9a128a7ba9 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(116); + expect(store.getDatabase().getSchemaVersion()).toBe(117); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/store-movement.test.ts b/packages/core/src/__tests__/store-movement.test.ts index d8d8ab1527..6d2fb238a6 100644 --- a/packages/core/src/__tests__/store-movement.test.ts +++ b/packages/core/src/__tests__/store-movement.test.ts @@ -75,6 +75,7 @@ describe("TaskStore", () => { const moved = await store.moveTask(task.id, "in-review"); expect(moved.autoMerge).toBeUndefined(); + expect(moved.autoMergeProvenance).toBeUndefined(); expect(allowsAutoMergeProcessing(moved, { autoMerge: true })).toBe(true); expect(allowsAutoMergeProcessing(moved, { autoMerge: false })).toBe(false); }); @@ -86,6 +87,7 @@ describe("TaskStore", () => { const moved = await store.moveTask(task.id, "in-review"); expect(moved.autoMerge).toBeUndefined(); + expect(moved.autoMergeProvenance).toBeUndefined(); expect(resolveEffectiveAutoMerge(moved, { autoMerge: false })).toBe(false); expect(resolveEffectiveAutoMerge(moved, { autoMerge: true })).toBe(true); }); @@ -96,23 +98,35 @@ describe("TaskStore", () => { const inheritedMoved = await store.moveTask(inherited.id, "in-review"); expect(inheritedMoved.autoMerge).toBeUndefined(); + expect(inheritedMoved.autoMergeProvenance).toBeUndefined(); expect(allowsAutoMergeProcessing(inheritedMoved, { autoMerge: false })).toBe(false); expect(allowsAutoMergeProcessing(inheritedMoved, { autoMerge: true })).toBe(true); const explicitTrue = await createInProgressTask("explicit true override"); await store.updateTask(explicitTrue.id, { autoMerge: true }); + const explicitTrueWithProvenance = await store.getTask(explicitTrue.id); + expect(explicitTrueWithProvenance?.autoMergeProvenance).toBe("user"); const explicitTrueMoved = await store.moveTask(explicitTrue.id, "in-review"); expect(explicitTrueMoved.autoMerge).toBe(true); + expect(explicitTrueMoved.autoMergeProvenance).toBe("user"); expect(allowsAutoMergeProcessing(explicitTrueMoved, { autoMerge: false })).toBe(true); expect(resolveEffectiveAutoMerge(explicitTrueMoved, { autoMerge: false })).toBe(true); const explicitFalse = await createInProgressTask("explicit false override"); await store.updateTask(explicitFalse.id, { autoMerge: false }); + const explicitFalseWithProvenance = await store.getTask(explicitFalse.id); + expect(explicitFalseWithProvenance?.autoMergeProvenance).toBe("user"); const explicitFalseMoved = await store.moveTask(explicitFalse.id, "in-review"); expect(explicitFalseMoved.autoMerge).toBe(false); + expect(explicitFalseMoved.autoMergeProvenance).toBe("user"); expect(allowsAutoMergeProcessing(explicitFalseMoved, { autoMerge: false })).toBe(false); expect(resolveEffectiveAutoMerge(explicitFalseMoved, { autoMerge: false })).toBe(false); expect(resolveEffectiveAutoMerge(explicitFalseMoved, { autoMerge: true })).toBe(false); + + await store.updateTask(explicitFalse.id, { autoMerge: null }); + const cleared = await store.getTask(explicitFalse.id); + expect(cleared?.autoMerge).toBeUndefined(); + expect(cleared?.autoMergeProvenance).toBeUndefined(); }); }); diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index b79d8b63a3..b1fa2e2e2d 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -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(116); + expect(db.getSchemaVersion()).toBe(117); const index = db .prepare( diff --git a/packages/core/src/__tests__/task-merge.test.ts b/packages/core/src/__tests__/task-merge.test.ts index 8b32a07feb..b77c3ca2e9 100644 --- a/packages/core/src/__tests__/task-merge.test.ts +++ b/packages/core/src/__tests__/task-merge.test.ts @@ -52,11 +52,21 @@ describe("resolveEffectiveAutoMerge", () => { expect(resolveEffectiveAutoMerge(task, { autoMerge: false })).toBe(false); expect(resolveEffectiveAutoMerge(task, { autoMerge: true })).toBe(true); }); + + it("treats provenance as metadata and resolves solely from the value", () => { + expect(resolveEffectiveAutoMerge({ autoMerge: true, autoMergeProvenance: "legacy-stamp" }, { autoMerge: false })).toBe(true); + expect(resolveEffectiveAutoMerge({ autoMerge: false, autoMergeProvenance: "user" }, { autoMerge: true })).toBe(false); + expect(resolveEffectiveAutoMerge({ autoMerge: undefined, autoMergeProvenance: undefined }, { autoMerge: true })).toBe(true); + }); }); describe("allowsAutoMergeProcessing", () => { - it("lets explicit per-task true through when the global setting is off (FN per-task override)", () => { - expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: false })).toBe(true); + it("lets explicit per-task true with user provenance through when the global setting is off", () => { + expect(allowsAutoMergeProcessing({ autoMerge: true, autoMergeProvenance: "user" }, { autoMerge: false })).toBe(true); + }); + + it("still lets legacy-stamp true through at the gate so reconcile, not the gate, owns cleanup", () => { + expect(allowsAutoMergeProcessing({ autoMerge: true, autoMergeProvenance: "legacy-stamp" }, { autoMerge: false })).toBe(true); }); it("blocks tasks without an explicit override when the global setting is off", () => { @@ -64,7 +74,7 @@ describe("allowsAutoMergeProcessing", () => { expect(allowsAutoMergeProcessing(task, { autoMerge: true })).toBe(true); expect(allowsAutoMergeProcessing(task, { autoMerge: false })).toBe(false); expect(allowsAutoMergeProcessing(task, { autoMerge: true })).toBe(true); - expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: false })).toBe(false); + expect(allowsAutoMergeProcessing({ autoMerge: false, autoMergeProvenance: "user" }, { autoMerge: false })).toBe(false); }); it("lets everything through when the global setting is on — explicit false still flows so the merger can park it manual-required", () => { diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 72b20e8b18..81e46437dd 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 116; +const SCHEMA_VERSION = 117; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -250,6 +250,7 @@ CREATE TABLE IF NOT EXISTS tasks ( baseBranch TEXT, branch TEXT, autoMerge INTEGER, + autoMergeProvenance TEXT, executionStartBranch TEXT, baseCommitSha TEXT, modelPresetId TEXT, @@ -4697,6 +4698,13 @@ export class Database { }); } + // Migration 117: Auto-merge override provenance for legacy stamp cleanup. + if (version < 117) { + this.applyMigration(117, () => { + this.addColumnIfMissing("tasks", "autoMergeProvenance", "TEXT"); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f76948fea..6441dbe401 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -455,6 +455,7 @@ export { InvalidMergeQueueLeaseDurationError, HandoffInvariantViolationError, TransitionRejectionError, + type LegacyAutoMergeStampReconcileResult, } from "./store.js"; export { STOPWORDS, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index cca5349662..af54729181 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -191,6 +191,7 @@ interface TaskRow { executionStartBranch: string | null; branch: string | null; autoMerge: number | null; + autoMergeProvenance: string | null; baseCommitSha: string | null; modelPresetId: string | null; modelProvider: string | null; @@ -311,6 +312,7 @@ function defineTaskColumn( } const serializeTaskAutoMerge: TaskColumnDescriptor["serialize"] = (task) => task.autoMerge === undefined ? null : (task.autoMerge ? 1 : 0); +const serializeTaskAutoMergeProvenance: TaskColumnDescriptor["serialize"] = (task) => task.autoMergeProvenance ?? null; // Keep this descriptor order in lockstep with the named-column INSERT/UPSERT // clauses we generate below. SQLite binds by the explicit column list we emit, @@ -336,6 +338,7 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("baseBranch", (task) => task.baseBranch ?? null), defineTaskColumn("branch", (task) => task.branch ?? null), defineTaskColumn("autoMerge", serializeTaskAutoMerge), + defineTaskColumn("autoMergeProvenance", serializeTaskAutoMergeProvenance), defineTaskColumn("executionStartBranch", (task) => task.executionStartBranch ?? null), defineTaskColumn("baseCommitSha", (task) => task.baseCommitSha ?? null), defineTaskColumn("modelPresetId", (task) => task.modelPresetId ?? null), @@ -1407,6 +1410,15 @@ interface MoveTaskInternalOptions { const WORKFLOW_MOVE_POLICY_TIMEOUT_MS = 5000; +export interface LegacyAutoMergeStampReconcileResult { + taskId: string; + column: string; + cleared: boolean; +} + +const LEGACY_AUTO_MERGE_STAMP_MARKER_KEY = "legacyAutoMergeStampMarkedVersion"; +const LEGACY_AUTO_MERGE_STAMP_MARKER_VERSION = "1"; + export class TaskStore extends EventEmitter { private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL'; /** U6: sentinel effective-workflow id for default-workflow (null-selection) @@ -1807,6 +1819,14 @@ export class TaskStore extends EventEmitter { await this.migrateActiveArchivedTasksToArchiveDb(); await this.migrateAgentLogEntriesToFilesOnce(); await this.cleanupNoOpTaskMovedActivityRowsOnce(); + try { + await this.markLegacyAutoMergeStampsOnce(); + } catch (err) { + storeLog.warn("Legacy auto-merge stamp marker failed during init (non-fatal)", { + phase: "init:legacy-auto-merge-stamp-marker", + error: err instanceof Error ? err.message : String(err), + }); + } // U4: one-time per-project hard-move of MOVED_SETTINGS_KEYS into workflow // setting values (marker-gated, idempotent, never blocks startup). try { @@ -1920,6 +1940,9 @@ export class TaskStore extends EventEmitter { executionStartBranch: row.executionStartBranch || undefined, branch: row.branch || undefined, autoMerge: row.autoMerge === null ? undefined : row.autoMerge === 1, + autoMergeProvenance: row.autoMergeProvenance === "user" || row.autoMergeProvenance === "legacy-stamp" + ? row.autoMergeProvenance + : undefined, baseCommitSha: row.baseCommitSha || undefined, scopeOverride: row.scopeOverride ? true : undefined, scopeOverrideReason: row.scopeOverrideReason || undefined, @@ -2448,7 +2471,7 @@ export class TaskStore extends EventEmitter { const prefix = tableAlias ? `${tableAlias}.` : ""; return [ "id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep", - "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "executionStartBranch", "baseCommitSha", + "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "autoMergeProvenance", "executionStartBranch", "baseCommitSha", "modelPresetId", "modelProvider", "modelId", "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", @@ -2497,7 +2520,7 @@ export class TaskStore extends EventEmitter { private getTaskSelectClauseWithActivityLogLimit(limit: number): string { const columns = [ "id", "lineageId", "title", "description", "priority", "\"column\"", "status", "size", "reviewLevel", "currentStep", - "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "executionStartBranch", "baseCommitSha", + "worktree", "blockedBy", "overlapBlockedBy", "paused", "pausedReason", "userPaused", "baseBranch", "branch", "autoMerge", "autoMergeProvenance", "executionStartBranch", "baseCommitSha", "modelPresetId", "modelProvider", "modelId", "validatorModelProvider", "validatorModelId", "planningModelProvider", "planningModelId", @@ -4460,6 +4483,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} sourceMetadata: withTaskBranchContextInSourceMetadata(input.source?.sourceMetadata, input.branchContext), branchContext: input.branchContext, autoMerge: input.autoMerge, + autoMergeProvenance: input.autoMerge === undefined ? undefined : "user", column: input.column || "triage", dependencies: input.dependencies || [], breakIntoSubtasks: input.breakIntoSubtasks === true ? true : undefined, @@ -8053,20 +8077,28 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } else if (updates.baseBranch !== undefined) { task.baseBranch = updates.baseBranch; } + // Explicit task-level auto-merge overrides written through updateTask are + // user provenance. Task creation mirrors this for create-time overrides. if (updates.autoMerge === null) { task.autoMerge = undefined; + task.autoMergeProvenance = undefined; } else if (updates.autoMerge !== undefined) { task.autoMerge = updates.autoMerge; + task.autoMergeProvenance = "user"; } if (updates.branch === null) { task.branch = undefined; } else if (updates.branch !== undefined) { task.branch = updates.branch; } + // Keep in sync with the first autoMerge block above; both legacy update + // paths may run before persistence. if (updates.autoMerge === null) { task.autoMerge = undefined; + task.autoMergeProvenance = undefined; } else if (updates.autoMerge !== undefined) { task.autoMerge = updates.autoMerge; + task.autoMergeProvenance = "user"; } if (updates.executionStartBranch === null) { task.executionStartBranch = undefined; @@ -9366,6 +9398,118 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return event; } + private isLegacyAutoMergeStampCandidate(task: Pick): boolean { + return task.column === "in-review" && task.autoMerge === true && task.autoMergeProvenance !== "user"; + } + + private async listLegacyAutoMergeStampCandidates(): Promise { + const inReview = await this.listTasks({ column: "in-review" }); + return inReview.filter((task) => this.isLegacyAutoMergeStampCandidate(task)); + } + + /** + * Dry-run or apply the operator-driven cleanup for legacy review-entry + * auto-merge stamps. Dry-run is the default and only reports candidates. + * With apply=true, ambiguous legacy stamps are cleared so the task follows the + * live global autoMerge setting again. Explicit user overrides are never + * candidates and are preserved. + */ + async reconcileLegacyAutoMergeStamps(options?: { apply?: boolean }): Promise { + const candidates = await this.listLegacyAutoMergeStampCandidates(); + const results: LegacyAutoMergeStampReconcileResult[] = []; + + if (options?.apply !== true) { + return candidates.map((task) => ({ taskId: task.id, column: task.column, cleared: false })); + } + + for (const candidate of candidates) { + const current = await this.getTask(candidate.id); + if (!current || !this.isLegacyAutoMergeStampCandidate(current)) { + continue; + } + + const priorAutoMerge = current.autoMerge; + const priorProvenance = current.autoMergeProvenance; + current.autoMerge = undefined; + current.autoMergeProvenance = undefined; + current.updatedAt = new Date().toISOString(); + + await this.atomicWriteTaskJson(this.taskDir(current.id), current); + if (this.isWatching) this.taskCache.set(current.id, { ...current }); + this.emitTaskLifecycleEventSafely("task:updated", [current]); + + this.recordRunAuditEvent({ + taskId: current.id, + agentId: "system", + runId: `legacy-auto-merge-stamp-clear-${current.id}-${Date.now()}`, + domain: "database", + mutationType: "task:auto-merge-legacy-stamp-cleared", + target: current.id, + metadata: { + taskId: current.id, + priorAutoMerge, + priorAutoMergeProvenance: priorProvenance ?? null, + action: "cleared-to-follow-global-autoMerge", + }, + }); + results.push({ taskId: current.id, column: current.column, cleared: true }); + } + + return results; + } + + private async markLegacyAutoMergeStampsOnce(): Promise { + const markerRow = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(LEGACY_AUTO_MERGE_STAMP_MARKER_KEY) as + | { value: string } + | undefined; + if (markerRow?.value === LEGACY_AUTO_MERGE_STAMP_MARKER_VERSION) { + return; + } + + const candidates = await this.listLegacyAutoMergeStampCandidates(); + const markedTaskIds: string[] = []; + for (const candidate of candidates) { + const current = await this.getTask(candidate.id); + if (!current || !this.isLegacyAutoMergeStampCandidate(current)) { + continue; + } + current.autoMergeProvenance = "legacy-stamp"; + current.updatedAt = new Date().toISOString(); + await this.atomicWriteTaskJson(this.taskDir(current.id), current); + if (this.isWatching) this.taskCache.set(current.id, { ...current }); + this.emitTaskLifecycleEventSafely("task:updated", [current]); + markedTaskIds.push(current.id); + + this.recordRunAuditEvent({ + taskId: current.id, + agentId: "system", + runId: `legacy-auto-merge-stamp-mark-${current.id}-${Date.now()}`, + domain: "database", + mutationType: "task:auto-merge-legacy-stamp-marked", + target: current.id, + metadata: { + taskId: current.id, + autoMerge: true, + autoMergeProvenance: "legacy-stamp", + action: "marked-only-no-behavior-change", + }, + }); + } + + this.db.prepare(` + INSERT INTO __meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(LEGACY_AUTO_MERGE_STAMP_MARKER_KEY, LEGACY_AUTO_MERGE_STAMP_MARKER_VERSION); + this.db.bumpLastModified(); + + storeLog.log("legacy auto-merge stamp marker completed", { + phase: "legacy-auto-merge-stamp-marker", + markedCount: markedTaskIds.length, + markedTaskIds: markedTaskIds.slice(0, 50), + truncated: markedTaskIds.length > 50, + }); + } + /** * Query run-audit events with optional filters. * @@ -10928,6 +11072,15 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} this.taskCache.set(task.id, { ...task }); } + try { + await this.markLegacyAutoMergeStampsOnce(); + } catch (err) { + storeLog.warn("Legacy auto-merge stamp marker failed during watch startup (non-fatal)", { + phase: "watch:legacy-auto-merge-stamp-marker", + error: err instanceof Error ? err.message : String(err), + }); + } + if (!this.donePauseBackfillDone) { const repairedTaskIds: string[] = []; for (const [taskId, cachedTask] of this.taskCache.entries()) { diff --git a/packages/core/src/task-merge.ts b/packages/core/src/task-merge.ts index 0caaac894d..caa3d7bcde 100644 --- a/packages/core/src/task-merge.ts +++ b/packages/core/src/task-merge.ts @@ -38,7 +38,8 @@ function isFusionSiblingBranch(branch: string): boolean { * Resolves a task's effective auto-merge behavior. * Explicit per-task values (`true`/`false`) take precedence over the global * setting; when `task.autoMerge` is `undefined`, falls back to - * `settings.autoMerge`. + * `settings.autoMerge`. `autoMergeProvenance` is metadata used by legacy-stamp + * remediation; this resolver intentionally keys only on the value. */ export function resolveEffectiveAutoMerge( task: Pick, @@ -52,8 +53,9 @@ export function resolveEffectiveAutoMerge( * Additive relative to the global setting: when `settings.autoMerge` is on, * every task flows through — tasks with an explicit `autoMerge: false` are * parked as `manual-required` downstream by the merger, not silently skipped - * here. When the global setting is off, only tasks with an explicit per-task - * `autoMerge: true` override proceed. Distinct from + * here. When the global setting is off, only tasks with a per-task + * `autoMerge: true` value proceed; legacy stamp provenance is surfaced and + * reconciled separately. Distinct from * `resolveEffectiveAutoMerge`, which resolves the effective boolean and would * (incorrectly for processing gates) starve the manual-required parking path. */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8aaef1f081..c8a0b1f83e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2131,12 +2131,16 @@ export interface Task { * Defaults to the project default branch when omitted. */ baseBranch?: string; /** Per-task auto-merge override. - * `undefined` means no explicit per-task value: follow `settings.autoMerge` - * and snapshot that global setting when the task enters `in-review`. - * `true`/`false` are explicit user overrides and take precedence. + * `undefined` means no explicit per-task value: follow live `settings.autoMerge`. + * `true`/`false` are explicit overrides when paired with `autoMergeProvenance: "user"`. * Distinct from GitHub PR metadata (`PrInfo.autoMergeOnGreen` / * `PrInfo.autoMergeStrategy`), which must not be conflated with this field. */ autoMerge?: boolean; + /** Provenance for `autoMerge`. + * `"user"` means a sticky explicit user-set override. + * `"legacy-stamp"` means an ambiguous value written by the pre-FN-6245 + * review-entry stamp and is operator-clearable. Absent means unknown/none. */ + autoMergeProvenance?: "user" | "legacy-stamp"; /** Actual git working branch name used for this task's worktree. May differ from * the conventional `fn/{task-id}` when conflict recovery generated a * unique suffixed name (e.g., `fn/fn-042-2`). */ diff --git a/packages/engine/src/__tests__/automerge-toggle-legacy-advisory.test.ts b/packages/engine/src/__tests__/automerge-toggle-legacy-advisory.test.ts new file mode 100644 index 0000000000..3713b786c2 --- /dev/null +++ b/packages/engine/src/__tests__/automerge-toggle-legacy-advisory.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { ProjectEngine } from "../project-engine.js"; +import { runtimeLog } from "../logger.js"; +import type { Settings, Task } from "@fusion/core"; + +function makeSettings(autoMerge: boolean): Settings { + return { + autoMerge, + globalPause: false, + enginePaused: false, + maintenanceIntervalMs: 900_000, + } as Settings; +} + +function makeEngineHarness(tasks: Task[]) { + const events = new EventEmitter(); + const auditEvents: unknown[] = []; + const store = Object.assign(events, { + listTasks: vi.fn(async ({ column }: { column?: string } = {}) => tasks.filter((task) => !column || task.column === column)), + recordRunAuditEvent: vi.fn((event: unknown) => { + auditEvents.push(event); + return event; + }), + updateTask: vi.fn(), + moveTask: vi.fn(), + pauseTask: vi.fn(), + }); + const engine = Object.create(ProjectEngine.prototype) as ProjectEngine & { + settingsHandlers: Array<(payload: { settings: Settings; previous: Settings }) => Promise | void>; + legacyAutoMergeStampAdvisoryEmitted: boolean; + mergeAbortController: AbortController | null; + activeMergeSession: null; + scheduleMergeActiveReconciliation: (intervalMs: number) => void; + }; + engine.settingsHandlers = []; + engine.legacyAutoMergeStampAdvisoryEmitted = false; + engine.mergeAbortController = null; + engine.activeMergeSession = null; + engine.scheduleMergeActiveReconciliation = vi.fn(); + (engine as any).runtime = {}; + (engine as any).automationStore = null; + (engine as any).wireSettingsListeners(store); + return { engine, store, auditEvents }; +} + +describe("auto-merge toggle legacy advisory", () => { + it("emits an operator advisory on global autoMerge OFF for legacy in-review stamps without mutating tasks", async () => { + const legacy = { + id: "FN-LEGACY", + column: "in-review", + autoMerge: true, + autoMergeProvenance: "legacy-stamp", + } as Task; + const absent = { + id: "FN-ABSENT", + column: "in-review", + autoMerge: true, + } as Task; + const user = { + id: "FN-USER", + column: "in-review", + autoMerge: true, + autoMergeProvenance: "user", + } as Task; + const todoLegacy = { + id: "FN-TODO", + column: "todo", + autoMerge: true, + autoMergeProvenance: "legacy-stamp", + } as Task; + const { engine, store, auditEvents } = makeEngineHarness([legacy, absent, user, todoLegacy]); + const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined as any); + + try { + const autoMergeOffHandler = engine.settingsHandlers[2]; + await autoMergeOffHandler?.({ settings: makeSettings(false), previous: makeSettings(true) }); + + expect(store.listTasks).toHaveBeenCalledWith({ column: "in-review" }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain("FN-LEGACY"); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain("FN-ABSENT"); + expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain("FN-USER"); + expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain("FN-TODO"); + + expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(1); + expect(auditEvents[0]).toMatchObject({ + domain: "database", + mutationType: "task:auto-merge-legacy-stamp-advisory", + target: "settings.autoMerge", + metadata: { + taskIds: ["FN-LEGACY", "FN-ABSENT"], + changedTaskState: false, + }, + }); + expect(store.updateTask).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.pauseTask).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it("does not advise for genuine user overrides or non-off transitions", async () => { + const user = { + id: "FN-USER", + column: "in-review", + autoMerge: true, + autoMergeProvenance: "user", + } as Task; + const { engine, store } = makeEngineHarness([user]); + const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined as any); + + try { + const autoMergeOffHandler = engine.settingsHandlers[2]; + await autoMergeOffHandler?.({ settings: makeSettings(true), previous: makeSettings(false) }); + await autoMergeOffHandler?.({ settings: makeSettings(false), previous: makeSettings(false) }); + await autoMergeOffHandler?.({ settings: makeSettings(false), previous: makeSettings(true) }); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(store.recordRunAuditEvent).not.toHaveBeenCalled(); + expect(store.updateTask).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 43be5db786..e50a60c234 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -391,6 +391,7 @@ export class ProjectEngine { private taskUpdatedHandler?: (...args: any[]) => void; private taskDeletedHandler?: (...args: any[]) => void; private autostashOrphansHandler?: (...args: any[]) => void; + private legacyAutoMergeStampAdvisoryEmitted = false; constructor( private config: ProjectRuntimeConfig, @@ -1639,6 +1640,43 @@ export class ProjectEngine { return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task); } + private async emitLegacyAutoMergeStampAdvisory(store: TaskStore): Promise { + if (this.legacyAutoMergeStampAdvisoryEmitted) { + return; + } + this.legacyAutoMergeStampAdvisoryEmitted = true; + + try { + const candidates = (await store.listTasks({ column: "in-review" })) + .filter((task) => task.autoMerge === true && task.autoMergeProvenance !== "user"); + if (candidates.length === 0) { + return; + } + + const taskIds = candidates.map((task) => task.id); + runtimeLog.warn( + `Global auto-merge was turned off, but ${taskIds.length} legacy in-review task(s) still have task.autoMerge=true without user provenance and may continue to auto-merge: ${taskIds.join(", ")}. Run reconcileLegacyAutoMergeStamps({ apply: true }) to clear these legacy stamps after review.`, + ); + store.recordRunAuditEvent({ + agentId: "system", + runId: `legacy-auto-merge-stamp-advisory-${Date.now()}`, + domain: "database", + mutationType: "task:auto-merge-legacy-stamp-advisory", + target: "settings.autoMerge", + metadata: { + taskIds, + candidateCount: taskIds.length, + recommendation: "Run reconcileLegacyAutoMergeStamps({ apply: true }) to clear legacy stamps after operator review.", + changedTaskState: false, + }, + }); + } catch (err: unknown) { + runtimeLog.warn( + `Legacy auto-merge stamp advisory failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick): number { const eligible = sortTasksByPriorityThenAgeAndId( tasks.filter((t) => !t.paused && this.canMergeTask(t as any) && this.allowInReviewMergeProcessing(t, settings)) as Task[], @@ -3316,7 +3354,23 @@ export class ProjectEngine { store.on("settings:updated", onGlobalPause); this.settingsHandlers.push(onGlobalPause); - // 3. Global unpause — resume orphaned tasks + sweep in-review + // 3. Auto-merge OFF — legacy pre-provenance stamps are ambiguous, so only + // advise operators about clearable candidates; do not mutate task state. + const onAutoMergeDisabled = async ({ + settings: s, + previous: prev, + }: { + settings: Settings; + previous: Settings; + }) => { + if (prev.autoMerge !== false && s.autoMerge === false) { + await this.emitLegacyAutoMergeStampAdvisory(store); + } + }; + store.on("settings:updated", onAutoMergeDisabled); + this.settingsHandlers.push(onAutoMergeDisabled); + + // 4. Global unpause — resume orphaned tasks + sweep in-review const onGlobalUnpause = async ({ settings: s, previous: prev, @@ -3332,7 +3386,7 @@ export class ProjectEngine { store.on("settings:updated", onGlobalUnpause); this.settingsHandlers.push(onGlobalUnpause); - // 4. Engine unpause — same as global unpause + // 5. Engine unpause — same as global unpause const onEngineUnpause = async ({ settings: s, previous: prev, @@ -3348,7 +3402,7 @@ export class ProjectEngine { store.on("settings:updated", onEngineUnpause); this.settingsHandlers.push(onEngineUnpause); - // 5. Maintenance interval change — reschedule mergeActive reconciliation + // 6. Maintenance interval change — reschedule mergeActive reconciliation const onMaintenanceIntervalChange = ({ settings: s, previous: prev, @@ -3368,7 +3422,7 @@ export class ProjectEngine { store.on("settings:updated", onMaintenanceIntervalChange); this.settingsHandlers.push(onMaintenanceIntervalChange); - // 6. Stuck task timeout change — trigger immediate check + // 7. Stuck task timeout change — trigger immediate check const onStuckTimeoutChange = async ({ settings: s, previous: prev, @@ -3394,7 +3448,7 @@ export class ProjectEngine { store.on("settings:updated", onStuckTimeoutChange); this.settingsHandlers.push(onStuckTimeoutChange); - // 7. Memory maintenance settings change — sync automations + // 8. Memory maintenance settings change — sync automations const onInsightSettingsChange = async ({ settings: s, previous: prev, @@ -3440,7 +3494,7 @@ export class ProjectEngine { store.on("settings:updated", onInsightSettingsChange); this.settingsHandlers.push(onInsightSettingsChange); - // 8. Auto-summarize settings change — sync automation + // 9. Auto-summarize settings change — sync automation const onAutoSummarizeSettingsChange = async ({ settings: s, previous: prev, @@ -3476,7 +3530,7 @@ export class ProjectEngine { store.on("settings:updated", onAutoSummarizeSettingsChange); this.settingsHandlers.push(onAutoSummarizeSettingsChange); - // 9. Scheduled eval settings change — sync automation + // 10. Scheduled eval settings change — sync automation const onScheduledEvalSettingsChange = async ({ settings: s, previous: prev,