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:
5
.changeset/fn-6277-legacy-automerge-stamp-cleanup.md
Normal file
5
.changeset/fn-6277-legacy-automerge-stamp-cleanup.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`. |
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -91,6 +91,6 @@ describe("goals schema", () => {
|
||||
});
|
||||
|
||||
it("reports schema version 101", () => {
|
||||
expect(db.getSchemaVersion()).toBe(116);
|
||||
expect(db.getSchemaVersion()).toBe(117);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -455,6 +455,7 @@ export {
|
||||
InvalidMergeQueueLeaseDurationError,
|
||||
HandoffInvariantViolationError,
|
||||
TransitionRejectionError,
|
||||
type LegacyAutoMergeStampReconcileResult,
|
||||
} from "./store.js";
|
||||
export {
|
||||
STOPWORDS,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user