From ff1bb20b8f01a07347db5363e9ee0f3da81ec5ff Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 10:55:26 -0700 Subject: [PATCH] docs: capture per-task auto-merge override learning and seed CONCEPTS.md Document the trigger-layer gating bug fixed in this PR under docs/solutions/logic-errors/, seed CONCEPTS.md with the merge-lifecycle vocabulary, and surface both knowledge stores in AGENTS.md's reference docs index. --- AGENTS.md | 2 + CONCEPTS.md | 32 +++++ ...merge-override-ignored-by-trigger-gates.md | 112 ++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 CONCEPTS.md create mode 100644 docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md diff --git a/AGENTS.md b/AGENTS.md index 266a5e1053..510c306842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,8 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - `./docs/soft-delete-verification-matrix.md` — mandatory soft-delete verification matrix. - `./docs/cli-reference.md` — CLI and terminal UI reference. - `./docs/contributing.md` — contributing conventions and release-adjacent context. +- `./docs/solutions/` — documented solutions to past problems (bugs, patterns, conventions), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in documented areas. +- `./CONCEPTS.md` — shared domain vocabulary (entities, named processes, status concepts). Relevant when orienting to the codebase or discussing domain concepts. ### Lazy-Loaded Heavy Views diff --git a/CONCEPTS.md b/CONCEPTS.md new file mode 100644 index 0000000000..9f015d0e39 --- /dev/null +++ b/CONCEPTS.md @@ -0,0 +1,32 @@ +# Concepts + +Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all. + +## Merge lifecycle + +### Task +The core board entity: a unit of work that moves through columns (triage, todo, in-progress, in-review, done, archived) and is executed by agents. A Task carries its own per-task settings that can override project-level defaults. + +### Auto-merge +The named process that automatically lands a completed Task's branch onto its merge target once the Task reaches In-review and passes its merge blockers. Gated twice: a project-level setting enables it globally, and each Task may carry an explicit per-task override. + +The per-task override takes precedence in both directions: an explicit per-task enable proceeds even when the global setting is off, and an explicit per-task disable routes the merge to Manual-required even when the global setting is on. Trigger-layer gates (enqueue, Self-healing sweeps) must evaluate additively — global on lets everything through for downstream routing; global off admits only explicit per-task enables — rather than collapsing the override to a single effective value, which would starve Manual-required routing. + +### In-review +The Task status column between execution and completion: work is done and the branch awaits merging. An In-review Task either auto-merges, waits for a human merge (PR-based/manual flow), or surfaces a stall diagnostic when it sits unprocessed longer than expected. Tasks not eligible for Auto-merge processing intentionally remain In-review until a human acts — recovery sweeps must not move them. + +### Merge queue +The ordered line of In-review Tasks awaiting Auto-merge, with a single merge active at a time. Tasks enter only through trigger gates (engine startup sweep, periodic retry, unpause, and the moved-to-review fast path); a Task filtered out at a gate is invisible to the merger regardless of its own settings. + +### Manual-required +The merge-request state for a Task whose merge needs an explicit human go-ahead — typically a Task with auto-merge explicitly disabled under a globally-enabled project. Reaching this state requires the Task to flow through the Merge queue trigger gates; upstream filtering that excludes such Tasks strands them In-review instead of parking them here. + +### Self-healing sweep +A recurring background scan that detects and repairs stuck Task states — stalled In-review Tasks, confirmed merges never finalized, ghost or limbo states, exhausted retries. Sweeps respect the same Auto-merge eligibility as the Merge queue: they may inspect any Task but mutate only those eligible for auto-merge processing. + +### Shared branch group +A set of Tasks integrating into a common shared branch instead of each merging straight to the project's default branch. Member integration (task branch → shared branch) is a soft pre-integration step exempt from the global auto-merge gate; promotion (shared branch → default branch) is gated separately. + +## Flagged ambiguities + +- "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated. diff --git a/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md new file mode 100644 index 0000000000..926d0f86ba --- /dev/null +++ b/docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md @@ -0,0 +1,112 @@ +--- +title: Per-task auto-merge override ignored by trigger-layer gates +date: 2026-06-03 +category: logic-errors +module: engine +problem_type: logic_error +component: background_job +symptoms: + - "Tasks with per-task autoMerge:true never auto-merged when global settings.autoMerge was off" + - "Override tasks reached in-review and sat there indefinitely with no error surfaced" + - "In-review self-healing sweeps short-circuited on the global setting and never enqueued the merge" +root_cause: logic_error +resolution_type: code_fix +severity: high +related_components: + - merger + - self-healing + - store +tags: + - auto-merge + - per-task-override + - merge-queue + - self-healing + - engine + - trigger-gate +--- + +# Per-task auto-merge override ignored by trigger-layer gates + +## Problem + +A per-task `autoMerge: true` override was honored only by the merger itself, but every *trigger-layer* gate (engine enqueue, 19 self-healing sweeps, store stall-signal hydration) checked the global `settings.autoMerge` alone. With global auto-merge OFF, override tasks were never enqueued and sat in `in-review` forever. Fixed in PR Runfusion/Fusion#1356. + +## Symptoms + +- User disabled auto-merge globally but enabled it on individual tasks. +- Those individually-enabled tasks reached `in-review` and stayed there indefinitely — never picked up, never merged. +- No error surfaced: the tasks were simply never *triggered* into the merge pipeline, so the merger's per-task handling never ran. + +## What Didn't Work + +- **Assuming the downstream merger check was enough.** The only code consulting `task.autoMerge` was the merger (`packages/engine/src/merger.ts` ~7958: `task.autoMerge === false` → `manual-required`). That runs *after* enqueue. The enqueue gate `allowInReviewMergeProcessing` (`packages/engine/src/project-engine.ts:1386`) and 19 self-healing sweeps short-circuited on `settings.autoMerge` before the task ever reached the merger — so the per-task flag was dead code from the user's perspective. Notably, the feature issues (Runfusion/Fusion#1150, #1152, #1153) shipped the data model, a resolver (`resolveEffectiveAutoMerge`), and the dashboard control — #1152 even claimed engine merge-gating used the resolved value — but no trigger gate actually consulted it. +- **Reaching for `resolveEffectiveAutoMerge` at the gates.** The existing resolver `task.autoMerge ?? settings.autoMerge` (`packages/core/src/task-merge.ts`) looks like the natural gate, but using it would *regress* the global-ON + `autoMerge:false` case: those tasks must still flow into the merger so it can park them as `manual-required` (and so merged-task finalization sweeps still finalize them). Plain resolution would skip them at the trigger, stranding manually-merged tasks in `in-review`. +- **Slim-projection gotcha.** Per-task gating reads `task.autoMerge` off rows from slim task projections. If the `autoMerge` column were missing from `getTaskSelectClause` (`packages/core/src/store.ts` ~1976), the gate would silently see `undefined` and the override would fail with no error. (Verified present — but a real trap when adding per-row predicates.) + +## Solution + +New core predicate, **additive** to the global setting (`packages/core/src/task-merge.ts`): + +```ts +export function allowsAutoMergeProcessing( + task: Pick, + settings: Pick, +): boolean { + return settings.autoMerge !== false || task.autoMerge === true; +} +``` + +Applied at three trigger layers: + +1. **Enqueue gate** (`project-engine.ts:1386`), which fronts all four enqueue paths (startup sweep, periodic retry, unpause, task-moved fast path): + + ```ts + // before + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return settings.autoMerge || isSharedBranchGroupMemberIntegration(task); + } + // after + private allowInReviewMergeProcessing(task: Pick, settings: Pick): boolean { + return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task); + } + ``` + +2. **All 19 self-healing sweeps** (`self-healing.ts`): the function-level early returns (`if (settings.autoMerge === false) return 0;`) were replaced by per-task filtering inside each sweep's candidate set, e.g.: + + ```ts + const candidates = tasks.filter((t) => + t.column === "in-review" && + allowsAutoMergeProcessing(t, settings) && + !t.paused && /* ... */); + ``` + +3. **Store stall-signal hydration** (`store.ts`, 6 sites): `autoMerge: settings.autoMerge` → `autoMerge: allowsAutoMergeProcessing(task, settings)` in the `getInReviewStallReason` / `getInReviewStalledSignal` contexts, so board diagnostics reflect that override tasks *are* being processed. + +The self-healing contract also changed: from "skip the whole sweep when global is off" to "list tasks, but mutate nothing without a per-task override." FN-5147 tests that asserted `listTasks` was never called were updated to assert the mutation-free guarantee instead. This extends — and stays consistent with — the AGENTS.md `autoMerge: false` callout (FN-5147): self-healing still never moves override-less `in-review` tasks when auto-merge is off. + +## Why This Works + +The root cause was a flag consulted only where the *action* runs, not where processing is *triggered*. Adding the override evaluation to every trigger gate closes the gap. + +Additive (`settings.autoMerge !== false || task.autoMerge === true`) is deliberately chosen over resolution (`task.autoMerge ?? settings.autoMerge`): + +- **Global ON:** `settings.autoMerge !== false` is already `true`, so the predicate is a no-op — every task flows through exactly as before, including `autoMerge:false` tasks that the merger then parks as `manual-required`. Resolution would have excluded those, breaking manual-required parking and finalization. +- **Global OFF:** the first term is `false`, so only `task.autoMerge === true` tasks proceed — exactly the missing override path. + +It changes nothing when global is ON and adds only the explicit-true path when global is OFF. + +## Prevention + +When adding a per-entity override to a behavior that's gated on a global setting, the override must be consulted **where the behavior is TRIGGERED, not just where the action runs.** A check at the merger (the action) is invisible if upstream enqueue/sweep gates already filtered the entity out. + +- **Grep every gate on the global setting** before declaring the override wired: here `settings.autoMerge` appeared at 1 enqueue gate, 19 sweep guards, and 6 hydration sites — all needed updating. A search for the global key, not just the new override field, surfaces the dead-flag sites. +- **Prefer additive gating over effective-value resolution for *processing* gates.** Resolution collapses three states (global-on/off × per-task true/false/unset) into one boolean and can starve a needed downstream branch (the manual-required parking path). Gate on "should this be processed at all," resolve the actual behavior later. +- **Watch slim projections:** per-row predicates require the override column in the SELECT clause, or they silently read `undefined`. +- **Test matrix must cross global × per-task.** The fix shipped red-first unit tests for the predicate (`packages/core/src/__tests__/task-merge.test.ts`), the gate including the shared-group exemption (`packages/engine/src/__tests__/project-engine.test.ts`), and a self-healing test proving an **override task is processed while an override-less sibling stays skipped** (`packages/engine/src/__tests__/self-healing.test.ts`) — the latter is the canonical shape: two tasks differing only in `autoMerge` under global-OFF, asserting divergent outcomes. + +## Related Issues + +- Runfusion/Fusion#1356 — the fix PR (commit `ad468813d`) +- Runfusion/Fusion#1150, Runfusion/Fusion#1152, Runfusion/Fusion#1153 — the per-task auto-merge feature trio (data model + resolver, engine gating, dashboard control); #1152's gating claim is the gap this bug exposed +- Runfusion/Fusion#753 (FN-5147), Runfusion/Fusion#690 (FN-5052) — prior global `autoMerge:false` stall/lifecycle handling that the sweeps' guards came from +- AGENTS.md → "`autoMerge: false` callout (FN-5147)" — standing lifecycle rule this fix extends to per-task granularity