FN-6277: track and reconcile legacy auto-merge stamps

Track legacy auto-merge stamp provenance and add safe operator cleanup.

- Add autoMergeProvenance storage and migration support so user overrides can be distinguished from legacy review-entry stamps.
- Mark existing ambiguous in-review autoMerge=true tasks as legacy stamps without changing behavior, and expose a dry-run/apply reconciliation API to clear them safely.
- Emit a non-mutating advisory when global auto-merge is disabled while legacy stamped review tasks remain.
- Cover migration, persistence, reconciliation, movement, merge resolution, and advisory behavior with regression tests and docs.

Files changed:
 .../fn-6277-legacy-automerge-stamp-cleanup.md      |   5 +
 docs/architecture.md                               |   2 +-
 docs/settings-reference.md                         |   2 +-
 packages/core/src/__tests__/db-migrate.test.ts     |  30 ++--
 packages/core/src/__tests__/db.test.ts             |  44 +++---
 packages/core/src/__tests__/goals-schema.test.ts   |   2 +-
 packages/core/src/__tests__/insight-store.test.ts  |  10 +-
 .../legacy-automerge-stamp-reconcile.test.ts       | 144 +++++++++++++++++++
 .../src/__tests__/merge-request-record.test.ts     |   2 +-
 packages/core/src/__tests__/mission-store.test.ts  |   2 +-
 packages/core/src/__tests__/run-audit.test.ts      |   4 +-
 .../core/src/__tests__/store-merge-queue.test.ts   |   2 +-
 packages/core/src/__tests__/store-movement.test.ts |  14 ++
 packages/core/src/__tests__/task-documents.test.ts |   2 +-
 packages/core/src/__tests__/task-merge.test.ts     |  16 ++-
 packages/core/src/db.ts                            |  10 +-
 packages/core/src/index.ts                         |   1 +
 packages/core/src/store.ts                         | 157 ++++++++++++++++++++-
 packages/core/src/task-merge.ts                    |   8 +-
 packages/core/src/types.ts                         |  10 +-
 .../automerge-toggle-legacy-advisory.test.ts       | 128 +++++++++++++++++
 packages/engine/src/project-engine.ts              |  68 ++++++++-
 22 files changed, 593 insertions(+), 70 deletions(-)

Fusion-Task-Id: FN-6277

Fusion-Task-Lineage: 22d36519-f2ba-4ca7-8a09-12fc803c9a5b
This commit is contained in:
gsxdsm
2026-06-13 05:06:05 -07:00
parent 7eafa91dd7
commit bffae81a98
22 changed files with 593 additions and 70 deletions

View File

@@ -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.

View File

@@ -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 <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` 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/<id>` 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/<id>` 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/<id>` 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.

View File

@@ -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"`. |

View File

@@ -715,7 +715,7 @@ describe("schema migration", () => {
const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null };
expect(row.deletedAt).toBeNull();
expect(db.getSchemaVersion()).toBe(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 }>;

View File

@@ -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 }>;

View File

@@ -91,6 +91,6 @@ describe("goals schema", () => {
});
it("reports schema version 101", () => {
expect(db.getSchemaVersion()).toBe(116);
expect(db.getSchemaVersion()).toBe(117);
});
});

View File

@@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(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

View File

@@ -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<Task> {
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<Task> {
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<void> {
(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<void> {
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 });
}
});
});

View File

@@ -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 () => {

View File

@@ -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", () => {

View File

@@ -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);
});
});
});

View File

@@ -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 () => {

View File

@@ -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();
});
});

View File

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

View File

@@ -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", () => {

View File

@@ -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");
});
}
}
/**

View File

@@ -455,6 +455,7 @@ export {
InvalidMergeQueueLeaseDurationError,
HandoffInvariantViolationError,
TransitionRejectionError,
type LegacyAutoMergeStampReconcileResult,
} from "./store.js";
export {
STOPWORDS,

View File

@@ -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<TaskStoreEvents> {
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<TaskStoreEvents> {
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<TaskStoreEvents> {
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<TaskStoreEvents> {
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<TaskStoreEvents> {
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<Task, "column" | "autoMerge" | "autoMergeProvenance">): boolean {
return task.column === "in-review" && task.autoMerge === true && task.autoMergeProvenance !== "user";
}
private async listLegacyAutoMergeStampCandidates(): Promise<Task[]> {
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<LegacyAutoMergeStampReconcileResult[]> {
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<void> {
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()) {

View File

@@ -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<Task, "autoMerge">,
@@ -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.
*/

View File

@@ -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`). */

View File

@@ -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> | 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();
}
});
});

View File

@@ -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<void> {
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<Settings, "autoMerge">): 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,