Merge pull request #1356 from Runfusion/gsxdsm/automerge
fix(engine): honor per-task auto-merge override when global auto-merge is off
This commit is contained in:
5
.changeset/per-task-automerge-override.md
Normal file
5
.changeset/per-task-automerge-override.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Respect per-task auto-merge overrides when the global auto-merge setting is off. Tasks with auto-merge explicitly enabled now get enqueued for merge and covered by the in-review self-healing sweeps (stall surfacing, merged-task finalization, retry recovery) even when the project-level setting is disabled; tasks without an explicit override keep the PR-based/manual review flow untouched.
|
||||
@@ -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
|
||||
|
||||
|
||||
32
CONCEPTS.md
Normal file
32
CONCEPTS.md
Normal file
@@ -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.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
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<Task, "autoMerge">,
|
||||
settings: Pick<Settings, "autoMerge">,
|
||||
): 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<Task, "branchContext">, settings: Pick<Settings, "autoMerge">): boolean {
|
||||
return settings.autoMerge || isSharedBranchGroupMemberIntegration(task);
|
||||
}
|
||||
// after
|
||||
private allowInReviewMergeProcessing(task: Pick<Task, "branchContext" | "autoMerge">, settings: Pick<Settings, "autoMerge">): 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.
|
||||
- **Check existing regression contracts before re-scoping a gate.** Review of the fix PR suggested exempting `todo`/`in-progress` candidates (execution-stage repair) from the auto-merge gate — but the repo's FN-5704 regression test ("short-circuits reclaim when autoMerge is false") deliberately keeps execution-stage reclaim inert in manual-review projects. Per-task gating applied uniformly preserves that contract while enabling overrides; exempting execution-stage recovery would be a separate, deliberate behavior change.
|
||||
- **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
|
||||
- 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
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getTaskHardMergeBlocker,
|
||||
getTaskMergeBlocker,
|
||||
isTaskReadyForMerge,
|
||||
allowsAutoMergeProcessing,
|
||||
isSharedBranchGroupMemberIntegration,
|
||||
resolveEffectiveAutoMerge,
|
||||
resolveEffectiveGroupAutoMerge,
|
||||
@@ -46,6 +47,23 @@ describe("resolveEffectiveAutoMerge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("blocks tasks without an explicit override when the global setting is off", () => {
|
||||
expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: false })).toBe(false);
|
||||
expect(allowsAutoMergeProcessing({ autoMerge: false }, { 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", () => {
|
||||
expect(allowsAutoMergeProcessing({ autoMerge: undefined }, { autoMerge: true })).toBe(true);
|
||||
expect(allowsAutoMergeProcessing({ autoMerge: true }, { autoMerge: true })).toBe(true);
|
||||
expect(allowsAutoMergeProcessing({ autoMerge: false }, { autoMerge: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveGroupAutoMerge", () => {
|
||||
it("prefers explicit true over global false", () => {
|
||||
expect(resolveEffectiveGroupAutoMerge({ autoMerge: true }, { autoMerge: false })).toBe(true);
|
||||
|
||||
@@ -318,6 +318,7 @@ export {
|
||||
getTaskHardMergeBlocker,
|
||||
getTaskCompletionBlocker,
|
||||
isTaskReadyForMerge,
|
||||
allowsAutoMergeProcessing,
|
||||
isSharedBranchGroupMemberIntegration,
|
||||
resolveEffectiveAutoMerge,
|
||||
resolveEffectiveGroupAutoMerge,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_
|
||||
import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js";
|
||||
import { resolveWorktrunkSettings, validateWorktrunkSettings } from "./worktrunk-settings.js";
|
||||
import { normalizeTaskPriority } from "./task-priority.js";
|
||||
import { allowsAutoMergeProcessing } from "./task-merge.js";
|
||||
import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js";
|
||||
import { GlobalSettingsStore } from "./global-settings.js";
|
||||
import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js";
|
||||
@@ -4597,7 +4598,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const task = this.rowToTask(row);
|
||||
task.inReviewStall = getInReviewStallReason(task, {
|
||||
now,
|
||||
autoMerge: settings.autoMerge,
|
||||
autoMerge: allowsAutoMergeProcessing(task, settings),
|
||||
engineActiveSinceMs: settings.engineActiveSinceMs,
|
||||
engineActivationGraceMs: settings.engineActivationGraceMs,
|
||||
});
|
||||
@@ -4610,7 +4611,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.inReviewStalled = getInReviewStalledSignal(task, {
|
||||
now,
|
||||
thresholdMs: settings.inReviewStalledThresholdMs,
|
||||
autoMerge: settings.autoMerge,
|
||||
autoMerge: allowsAutoMergeProcessing(task, settings),
|
||||
engineActiveSinceMs: settings.engineActiveSinceMs,
|
||||
engineActivationGraceMs: settings.engineActivationGraceMs,
|
||||
});
|
||||
@@ -4853,7 +4854,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const task = this.rowToTask(row);
|
||||
task.inReviewStall = getInReviewStallReason(task, {
|
||||
now,
|
||||
autoMerge: settings.autoMerge,
|
||||
autoMerge: allowsAutoMergeProcessing(task, settings),
|
||||
engineActiveSinceMs: settings.engineActiveSinceMs,
|
||||
engineActivationGraceMs: settings.engineActivationGraceMs,
|
||||
});
|
||||
@@ -4866,7 +4867,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.inReviewStalled = getInReviewStalledSignal(task, {
|
||||
now,
|
||||
thresholdMs: settings.inReviewStalledThresholdMs,
|
||||
autoMerge: settings.autoMerge,
|
||||
autoMerge: allowsAutoMergeProcessing(task, settings),
|
||||
engineActiveSinceMs: settings.engineActiveSinceMs,
|
||||
engineActivationGraceMs: settings.engineActivationGraceMs,
|
||||
});
|
||||
@@ -5016,7 +5017,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const task = this.rowToTask(row);
|
||||
task.inReviewStall = getInReviewStallReason(task, {
|
||||
now,
|
||||
autoMerge: settings.autoMerge,
|
||||
autoMerge: allowsAutoMergeProcessing(task, settings),
|
||||
engineActiveSinceMs: settings.engineActiveSinceMs,
|
||||
engineActivationGraceMs: settings.engineActivationGraceMs,
|
||||
});
|
||||
@@ -5029,7 +5030,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.inReviewStalled = getInReviewStalledSignal(task, {
|
||||
now,
|
||||
thresholdMs: settings.inReviewStalledThresholdMs,
|
||||
autoMerge: settings.autoMerge,
|
||||
autoMerge: allowsAutoMergeProcessing(task, settings),
|
||||
engineActiveSinceMs: settings.engineActiveSinceMs,
|
||||
engineActivationGraceMs: settings.engineActivationGraceMs,
|
||||
});
|
||||
|
||||
@@ -47,6 +47,23 @@ export function resolveEffectiveAutoMerge(
|
||||
return task.autoMerge ?? settings.autoMerge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for auto-merge *processing* (engine enqueue + self-healing sweeps).
|
||||
* 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
|
||||
* `resolveEffectiveAutoMerge`, which resolves the effective boolean and would
|
||||
* (incorrectly for processing gates) starve the manual-required parking path.
|
||||
*/
|
||||
export function allowsAutoMergeProcessing(
|
||||
task: Pick<Task, "autoMerge">,
|
||||
settings: Pick<Settings, "autoMerge">,
|
||||
): boolean {
|
||||
return settings.autoMerge !== false || task.autoMerge === true;
|
||||
}
|
||||
|
||||
// Resolves group → default-branch PROMOTION auto-merge. See resolveEffectiveAutoMerge for the per-task member→group-integration step; the two are distinct and must not be conflated.
|
||||
export function resolveEffectiveGroupAutoMerge(
|
||||
group: Pick<BranchGroup, "autoMerge">,
|
||||
|
||||
@@ -2670,3 +2670,97 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => {
|
||||
await engine.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowInReviewMergeProcessing per-task autoMerge override", () => {
|
||||
const gate = (task: Partial<Task>, settings: { autoMerge: boolean }) =>
|
||||
(createEngine() as any).allowInReviewMergeProcessing(task, settings) as boolean;
|
||||
|
||||
it("lets an explicit per-task autoMerge:true through when the global setting is off", () => {
|
||||
expect(gate({ autoMerge: true }, { autoMerge: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks tasks without a per-task override when the global setting is off", () => {
|
||||
expect(gate({}, { autoMerge: false })).toBe(false);
|
||||
expect(gate({ autoMerge: false }, { autoMerge: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps everything flowing when the global setting is on — explicit autoMerge:false is parked manual-required downstream", () => {
|
||||
expect(gate({}, { autoMerge: true })).toBe(true);
|
||||
expect(gate({ autoMerge: false }, { autoMerge: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("still exempts shared-branch-group member integration when the global setting is off", () => {
|
||||
expect(gate(
|
||||
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
|
||||
{ autoMerge: false },
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ## Surface Enumeration
|
||||
//
|
||||
// Known in-review merge entry surfaces in ProjectEngine, and how each enforces
|
||||
// the per-task `autoMerge` override invariant (a task with `autoMerge:true` must
|
||||
// still be enqueued for merge even when the global `autoMerge` setting is off):
|
||||
//
|
||||
// 1. Startup merge sweep (project-engine.ts ~:2857) ─┐
|
||||
// 2. Periodic merge retry sweep (project-engine.ts ~:2916) ─┼─ all call
|
||||
// 3. Resume-after-unpause sweep (project-engine.ts ~:2977) ─┘ enqueueEligibleInReviewTasks(...)
|
||||
// 4. task:moved fast path (project-engine.ts ~:1506) ─── inline allowInReviewMergeProcessing(...)
|
||||
//
|
||||
// Surfaces 1–3 funnel through `enqueueEligibleInReviewTasks`, whose filter is
|
||||
// `!t.paused && canMergeTask(t) && allowInReviewMergeProcessing(t, settings)`.
|
||||
// The behavior tests below exercise that shared funnel directly on a real engine
|
||||
// instance (with `internalEnqueueMerge` stubbed), so a regression in any of the
|
||||
// three sweep wrappers (wireAutoMerge / startupMergeSweep / scheduleMergeRetry /
|
||||
// resumeAfterUnpauseAndSweepInReview) that still routes through the funnel is
|
||||
// caught. Surface 4 (the task:moved fast path) shares the same
|
||||
// `allowInReviewMergeProcessing` gate, which is covered by the direct helper
|
||||
// tests above.
|
||||
|
||||
describe("enqueueEligibleInReviewTasks honors per-task autoMerge override (shared sweep funnel)", () => {
|
||||
const inReview = (id: string, overrides: Partial<Task> = {}): Task =>
|
||||
({
|
||||
id,
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
status: null,
|
||||
...overrides,
|
||||
}) as unknown as Task;
|
||||
|
||||
const setup = () => {
|
||||
const engine = createEngine() as any;
|
||||
const enqueueSpy = vi
|
||||
.spyOn(engine, "internalEnqueueMerge")
|
||||
.mockImplementation(() => true);
|
||||
const run = (tasks: Task[], settings: { autoMerge: boolean }): number =>
|
||||
engine.enqueueEligibleInReviewTasks(tasks, settings) as number;
|
||||
return { engine, enqueueSpy, run };
|
||||
};
|
||||
|
||||
it("enqueues an in-review task with autoMerge:true even when the global setting is off", () => {
|
||||
const { enqueueSpy, run } = setup();
|
||||
const count = run([inReview("FN-override", { autoMerge: true })], { autoMerge: false });
|
||||
expect(count).toBe(1);
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-override");
|
||||
});
|
||||
|
||||
it("does not enqueue a sibling task without an override in the same sweep when the global setting is off", () => {
|
||||
const { enqueueSpy, run } = setup();
|
||||
const count = run(
|
||||
[inReview("FN-override", { autoMerge: true }), inReview("FN-plain")],
|
||||
{ autoMerge: false },
|
||||
);
|
||||
expect(count).toBe(1);
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-override");
|
||||
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-plain");
|
||||
});
|
||||
|
||||
it("still enqueues a task with autoMerge:false when the global setting is on (parked manual-required downstream)", () => {
|
||||
const { enqueueSpy, run } = setup();
|
||||
const count = run([inReview("FN-explicit-false", { autoMerge: false })], { autoMerge: true });
|
||||
expect(count).toBe(1);
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-explicit-false");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ vi.mock("../merger.js", () => ({
|
||||
classifyOwnedLandedEvidence: vi.fn(),
|
||||
}));
|
||||
|
||||
import { SelfHealingManager, isBranchAheadOfBase } from "../self-healing.js";
|
||||
import { SelfHealingManager, isBranchAheadOfBase, MAX_AUTO_MERGE_RETRIES } from "../self-healing.js";
|
||||
import type { TaskStore, Settings, Task, AgentStore, Agent, NotificationProvider } from "@fusion/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
@@ -3365,7 +3365,8 @@ describe("SelfHealingManager", () => {
|
||||
const result = await managerWithRecovery.recoverMergeableReviewTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
// The sweep may list tasks to discover per-task autoMerge overrides,
|
||||
// but must not merge or enqueue anything without one.
|
||||
expect(store.mergeTask).not.toHaveBeenCalled();
|
||||
expect(enqueueMerge).not.toHaveBeenCalled();
|
||||
|
||||
@@ -3747,7 +3748,10 @@ describe("SelfHealingManager", () => {
|
||||
const result = await managerWithRecovery.finalizeNoOpReviewTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
// The sweep may list tasks to discover per-task autoMerge overrides,
|
||||
// but must not finalize anything without one.
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
@@ -8208,6 +8212,153 @@ describe("autoMerge gating for mutating in-review sweeps (FN-5147)", () => {
|
||||
taskStuckTimeoutMs: 1_000,
|
||||
maxPostReviewFixes: 1,
|
||||
});
|
||||
|
||||
// Seed real, stale in-review sweep candidates with NO per-task autoMerge
|
||||
// override. Each fixture matches a distinct covered sweep's candidate shape
|
||||
// and would be mutated if the per-task gate (allowsAutoMergeProcessing) were
|
||||
// ignored. Because the global setting is autoMerge:false and none of these
|
||||
// carry autoMerge:true, every sweep must enumerate them and skip them solely
|
||||
// due to the gate — which is the regression under test. The gate is the
|
||||
// first/early filter in each sweep, so candidates are dropped before any
|
||||
// store.getTask / git helper is reached.
|
||||
const stale = new Date(Date.now() - 600_000).toISOString();
|
||||
const seededInReviewCandidates = [
|
||||
// recoverStaleIncompleteReviewTasks + recoverGhostReviewTasks:
|
||||
// idle in-review with incomplete steps, stale.
|
||||
{
|
||||
id: "FN-GATE-INCOMPLETE",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
steps: [{ status: "pending" }],
|
||||
log: [],
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
// recoverInterruptedMergingTasks: stale `merging` status.
|
||||
{
|
||||
id: "FN-GATE-MERGING",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: "merging",
|
||||
steps: [],
|
||||
log: [],
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
// recoverMergedReviewTasks + recoverGhostReviewTasks(skip merge-confirmed):
|
||||
// mergeConfirmed:true stuck in in-review.
|
||||
{
|
||||
id: "FN-GATE-MERGED",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
steps: [],
|
||||
log: [],
|
||||
mergeDetails: { mergeConfirmed: true },
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
// recoverStuckMergeDeadlocks + recoverAlreadyMergedReviewTasks +
|
||||
// recoverOrphanOnlyScopeViolations: failed in-review, retries exhausted,
|
||||
// worktree present.
|
||||
{
|
||||
id: "FN-GATE-FAILED",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: "failed",
|
||||
steps: [],
|
||||
log: [],
|
||||
mergeRetries: MAX_AUTO_MERGE_RETRIES,
|
||||
worktree: "/tmp/test-project/.worktrees/FN-GATE-FAILED",
|
||||
branch: "fn/FN-GATE-FAILED",
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
// recoverReviewTasksWithFailedPreMergeSteps: idle in-review whose merge is
|
||||
// blocked specifically by a failed pre-merge workflow step, worktree set.
|
||||
{
|
||||
id: "FN-GATE-PREMERGE",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
steps: [],
|
||||
log: [],
|
||||
worktree: "/tmp/test-project/.worktrees/FN-GATE-PREMERGE",
|
||||
workflowStepResults: [{ phase: "pre-merge", status: "failed" }],
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
// recoverMissingWorktreeReviewFailures: failed by missing-worktree session
|
||||
// start, with step progress.
|
||||
{
|
||||
id: "FN-GATE-MISSINGWT",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: "failed",
|
||||
error: "Refusing to start coding agent in missing worktree: /tmp/gone",
|
||||
steps: [{ status: "done" }],
|
||||
log: [],
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
// recoverPartialProgressNoTaskDoneFailures: failed without fn_task_done,
|
||||
// partial step progress, not work-complete, retries available.
|
||||
{
|
||||
id: "FN-GATE-NOTASKDONE",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: "failed",
|
||||
error: "Agent finished without calling fn_task_done",
|
||||
steps: [{ status: "done" }, { status: "pending" }],
|
||||
log: [],
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
// recoverForeignOnlyContaminatedInReviewTasks: in-review with branch +
|
||||
// worktree, not merge-confirmed.
|
||||
{
|
||||
id: "FN-GATE-FOREIGN",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
branch: "fn/FN-GATE-FOREIGN",
|
||||
worktree: "/tmp/test-project/.worktrees/FN-GATE-FOREIGN",
|
||||
steps: [],
|
||||
log: [],
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
// recoverCompletionHandoffLimbo: idle in-review with no status/mergeDetails/
|
||||
// review, an aged "Task marked done by agent" log marker, no merge blocker.
|
||||
{
|
||||
id: "FN-GATE-HANDOFF",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
steps: [],
|
||||
log: [{ action: "Task marked done by agent", timestamp: stale }],
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
// reclaimSelfOwnedBranchConflicts: in-review branch-conflict-unrecoverable.
|
||||
// (No worktree, so even absent the gate it is skipped before any git call;
|
||||
// the gate is what the assertions verify.)
|
||||
{
|
||||
id: "FN-GATE-RECLAIM",
|
||||
column: "in-review",
|
||||
paused: true,
|
||||
pausedReason: "branch-conflict-unrecoverable",
|
||||
branch: "fn/FN-GATE-RECLAIM",
|
||||
steps: [],
|
||||
log: [],
|
||||
updatedAt: stale,
|
||||
columnMovedAt: stale,
|
||||
},
|
||||
] as unknown as Task[];
|
||||
|
||||
// Resolve fixtures only for the in-review column the sweeps enumerate; other
|
||||
// columns (todo / in-progress / triage) stay empty so the non-auto-merge-
|
||||
// gated branches of reclaim/foreign-only sweeps don't reach git helpers.
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (opts?: { column?: string }) =>
|
||||
opts?.column === "in-review" ? seededInReviewCandidates : [],
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -8227,26 +8378,105 @@ describe("autoMerge gating for mutating in-review sweeps (FN-5147)", () => {
|
||||
"recoverMissingWorktreeReviewFailures",
|
||||
"recoverPartialProgressNoTaskDoneFailures",
|
||||
"reclaimSelfOwnedBranchConflicts",
|
||||
] as const)("skips entirely when autoMerge is disabled (respects PR-based review flow): %s", async (methodName) => {
|
||||
] as const)("performs no mutations when autoMerge is disabled and no per-task override exists: %s", async (methodName) => {
|
||||
if (methodName === "recoverReviewTasksWithFailedPreMergeSteps") {
|
||||
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project", recoverFailedPreMergeStep: vi.fn() });
|
||||
}
|
||||
const result = await (manager as any)[methodName]();
|
||||
expect(result).toBe(0);
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
// Enumeration must have happened: the sweep listed real, stale in-review
|
||||
// candidates seeded above. Mutations are skipped solely because of the
|
||||
// per-task auto-merge gate (respects PR-based review flow) — so these
|
||||
// assertions are non-vacuous.
|
||||
expect(store.listTasks).toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips entirely when autoMerge is disabled (respects PR-based review flow): recoverCompletionHandoffLimbo", async () => {
|
||||
it("performs no mutations when autoMerge is disabled and no per-task override exists: recoverCompletionHandoffLimbo", async () => {
|
||||
const result = await manager.recoverCompletionHandoffLimbo();
|
||||
expect(result).toBeUndefined();
|
||||
expect(store.listTasks).not.toHaveBeenCalled();
|
||||
// The seeded FN-GATE-HANDOFF candidate carries an aged "Task marked done by
|
||||
// agent" marker and no merge blocker, so the sweep enumerates it and would
|
||||
// requeue/fail it absent the per-task gate.
|
||||
expect(store.listTasks).toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(store.logEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces in-review stalls for tasks with an explicit autoMerge:true override when the global setting is off", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z"));
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: false,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
taskStuckTimeoutMs: 60_000,
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-OVERRIDE",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: "merging",
|
||||
autoMerge: true,
|
||||
steps: [],
|
||||
log: [],
|
||||
updatedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(),
|
||||
columnMovedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const surfaced = await manager.surfaceInReviewStalls();
|
||||
|
||||
expect(surfaced).toBe(1);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-OVERRIDE",
|
||||
expect.stringContaining("In-review stall surfaced ["),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps skipping override-less siblings while processing the override task", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z"));
|
||||
const staleFields = {
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: "merging",
|
||||
steps: [],
|
||||
log: [],
|
||||
updatedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(),
|
||||
columnMovedAt: new Date(Date.parse("2026-01-01T00:10:00.000Z") - 600_000).toISOString(),
|
||||
};
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: false,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
taskStuckTimeoutMs: 60_000,
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "FN-OVERRIDE", autoMerge: true, ...staleFields },
|
||||
{ id: "FN-MANUAL", ...staleFields },
|
||||
]);
|
||||
|
||||
const surfaced = await manager.surfaceInReviewStalls();
|
||||
|
||||
expect(surfaced).toBe(1);
|
||||
expect(store.logEntry).not.toHaveBeenCalledWith(
|
||||
"FN-MANUAL",
|
||||
expect.stringContaining("In-review stall surfaced ["),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-5335 triple-proof no-action unit coverage", () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
ScheduledTask,
|
||||
AutomationRunResult,
|
||||
} from "@fusion/core";
|
||||
import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
|
||||
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
|
||||
@@ -1383,8 +1383,8 @@ export class ProjectEngine {
|
||||
* pushed wins. listTasks returns createdAt ASC — without this sort an
|
||||
* older low-priority task would start before a later urgent one.
|
||||
*/
|
||||
private allowInReviewMergeProcessing(task: Pick<Task, "branchContext">, settings: Pick<Settings, "autoMerge">): boolean {
|
||||
return settings.autoMerge || isSharedBranchGroupMemberIntegration(task);
|
||||
private allowInReviewMergeProcessing(task: Pick<Task, "branchContext" | "autoMerge">, settings: Pick<Settings, "autoMerge">): boolean {
|
||||
return allowsAutoMergeProcessing(task, settings) || isSharedBranchGroupMemberIntegration(task);
|
||||
}
|
||||
|
||||
private enqueueEligibleInReviewTasks(tasks: readonly Task[], settings: Pick<Settings, "autoMerge">): number {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { promisify } from "node:util";
|
||||
import { setImmediate as setImmediateCb } from "node:timers";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger, schedulerLog } from "./logger.js";
|
||||
import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
|
||||
@@ -2302,14 +2302,14 @@ export class SelfHealingManager {
|
||||
* Backward lifecycle move gated on triple proof (FN-5335).
|
||||
* When the predicate fails, emits `task:reclaim-self-owned-branch-conflict-no-action` and skips lifecycle mutation.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async reclaimSelfOwnedBranchConflicts(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const todoCandidates = await this.store.listTasks({ column: "todo", slim: true });
|
||||
const inProgressCandidates = await this.store.listTasks({ column: "in-progress", slim: true });
|
||||
const inProgressByWorktree = new Map<string, string>();
|
||||
@@ -2320,7 +2320,14 @@ export class SelfHealingManager {
|
||||
}
|
||||
const inReviewPausedCandidates = (await this.store.listTasks({ column: "in-review", slim: true }))
|
||||
.filter((task) => task.paused === true && task.pausedReason === "branch-conflict-unrecoverable");
|
||||
const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates];
|
||||
// Per-task auto-merge gating applies to ALL candidate columns, not just
|
||||
// in-review: the FN-5704 regression contract ("short-circuits reclaim
|
||||
// when autoMerge is false") deliberately keeps execution-stage reclaim
|
||||
// and resume-limbo escalation inert in manual-review projects. The
|
||||
// per-task override preserves that for override-less tasks while letting
|
||||
// explicit autoMerge:true tasks recover.
|
||||
const candidates = [...todoCandidates, ...inProgressCandidates, ...inReviewPausedCandidates]
|
||||
.filter((task) => allowsAutoMergeProcessing(task, settings));
|
||||
|
||||
const activeTaskIds = new Set<string>();
|
||||
if (this.options.agentStore) {
|
||||
@@ -4484,17 +4491,18 @@ export class SelfHealingManager {
|
||||
* Backward lifecycle move gated on triple proof (FN-5335).
|
||||
* When the unproven fallback predicate fails, emits `task:finalize-no-op-review-no-action` and skips lifecycle mutation.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async finalizeNoOpReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(t, settings) &&
|
||||
!t.paused &&
|
||||
!isSharedBranchGroupMemberIntegration(t) &&
|
||||
Boolean(t.worktree) &&
|
||||
@@ -4793,12 +4801,11 @@ export class SelfHealingManager {
|
||||
// "pull-request"`) — see GitHub issue #21.
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
|
||||
const mergeable = tasks.filter((t) =>
|
||||
t.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(t, settings) &&
|
||||
!t.paused &&
|
||||
t.status !== "failed" &&
|
||||
// Exclude transient merge statuses. Active merges should be left alone;
|
||||
@@ -4898,7 +4905,9 @@ export class SelfHealingManager {
|
||||
* per-task `postReviewFixCount` so a persistently-failing verifier cannot
|
||||
* ping-pong a task forever.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
* @returns Number of tasks sent back for fix
|
||||
*/
|
||||
async recoverReviewTasksWithFailedPreMergeSteps(): Promise<number> {
|
||||
@@ -4908,7 +4917,6 @@ export class SelfHealingManager {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
const maxFixes = settings.maxPostReviewFixes ?? 1;
|
||||
if (!Number.isFinite(maxFixes) || maxFixes <= 0) return 0;
|
||||
|
||||
@@ -4917,6 +4925,7 @@ export class SelfHealingManager {
|
||||
|
||||
const candidates = tasks.filter((task) => {
|
||||
if (task.column !== "in-review") return false;
|
||||
if (!allowsAutoMergeProcessing(task, settings)) return false;
|
||||
if (task.paused) return false;
|
||||
// Preserve terminal/human-handoff statuses (failed, awaiting-user-review,
|
||||
// merging, etc.). Only revive tasks that are otherwise idle.
|
||||
@@ -4994,13 +5003,14 @@ export class SelfHealingManager {
|
||||
* incomplete step instead of leaving the task stranded in review.
|
||||
* Backward lifecycle move gated on triple proof (FN-5335).
|
||||
* When the predicate fails, emits `task:stale-incomplete-review-no-action` and skips lifecycle mutation.
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async recoverStaleIncompleteReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
const timeoutMs = settings.taskStuckTimeoutMs;
|
||||
if (!timeoutMs || timeoutMs <= 0) return 0;
|
||||
|
||||
@@ -5008,6 +5018,7 @@ export class SelfHealingManager {
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const staleIncomplete = tasks.filter((task) =>
|
||||
task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
!task.paused &&
|
||||
!task.status &&
|
||||
task.steps.length > 0 &&
|
||||
@@ -5056,8 +5067,9 @@ export class SelfHealingManager {
|
||||
* Final-fallback recovery for `in-review` tasks that fell through every other
|
||||
* scan and have sat untouched longer than `taskStuckTimeoutMs`.
|
||||
*
|
||||
* When `settings.autoMerge` is disabled, this sweep is a no-op because
|
||||
* PR-based manual review intentionally leaves tasks in `in-review`.
|
||||
* Tasks not eligible for auto-merge processing (global `autoMerge` off
|
||||
* without an explicit per-task `autoMerge: true` override) are skipped
|
||||
* because PR-based manual review intentionally leaves them in `in-review`.
|
||||
*
|
||||
* The other review-recovery scans each require a specific shape (failed
|
||||
* pre-merge step, incomplete steps, mergeable + worktree present, confirmed
|
||||
@@ -5078,8 +5090,10 @@ export class SelfHealingManager {
|
||||
* each kick refreshes `updatedAt`, so a task that re-enters review and gets
|
||||
* stuck again can only be kicked once per `taskStuckTimeoutMs` window.
|
||||
*
|
||||
* When `settings.autoMerge === false`, this sweep is a no-op because those
|
||||
* projects intentionally use PR-based/manual in-review ownership.
|
||||
* Tasks not eligible for auto-merge processing (global `autoMerge` off
|
||||
* without an explicit per-task `autoMerge: true` override) are skipped
|
||||
* because those projects intentionally use PR-based/manual in-review
|
||||
* ownership.
|
||||
*
|
||||
* @returns Number of tasks kicked back to todo
|
||||
*/
|
||||
@@ -5087,8 +5101,6 @@ export class SelfHealingManager {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const cycleStartMs = Date.now();
|
||||
const timeoutMs = settings.taskStuckTimeoutMs;
|
||||
if (!timeoutMs || timeoutMs <= 0) return 0;
|
||||
@@ -5100,6 +5112,7 @@ export class SelfHealingManager {
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.deletedAt) continue;
|
||||
if (!allowsAutoMergeProcessing(task, settings)) continue;
|
||||
const signal = getInReviewStallReason(task, {
|
||||
now: cycleStartMs,
|
||||
activeMergeTaskId,
|
||||
@@ -5217,14 +5230,14 @@ export class SelfHealingManager {
|
||||
* - `surfaceStalePausedReviews()` owns paused in-review tasks.
|
||||
* - `surfaceInReviewStalls()` owns reason-driven in-review stalls.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async surfaceInReviewStalled(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const cycleStartMs = Date.now();
|
||||
const thresholdMs = settings.inReviewStalledThresholdMs;
|
||||
if (!thresholdMs || thresholdMs <= 0) return 0;
|
||||
@@ -5236,6 +5249,7 @@ export class SelfHealingManager {
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.deletedAt) continue;
|
||||
if (!allowsAutoMergeProcessing(task, settings)) continue;
|
||||
if (task.paused === true) continue;
|
||||
if (task.id === activeMergeTaskId || executingTaskIds.has(task.id)) continue;
|
||||
|
||||
@@ -5389,13 +5403,14 @@ export class SelfHealingManager {
|
||||
* Backward lifecycle move gated on triple proof (FN-5335).
|
||||
* When the predicate fails, emits `task:ghost-review-no-action` and skips lifecycle mutation.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async recoverGhostReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
const timeoutMs = settings.taskStuckTimeoutMs;
|
||||
if (!timeoutMs || timeoutMs <= 0) return 0;
|
||||
|
||||
@@ -5404,6 +5419,7 @@ export class SelfHealingManager {
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const ghosts = tasks.filter((task) =>
|
||||
task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
!task.paused &&
|
||||
!executingIds.has(task.id) &&
|
||||
!(task.status && GHOST_REVIEW_PRESERVED_STATUSES.has(task.status)) &&
|
||||
@@ -5465,7 +5481,9 @@ export class SelfHealingManager {
|
||||
* If no landed commit is found, it only clears the stale transient status so
|
||||
* the normal mergeable-review recovery can retry the merge.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
* @returns Number of tasks finalized or unblocked
|
||||
*/
|
||||
/**
|
||||
@@ -5486,8 +5504,9 @@ export class SelfHealingManager {
|
||||
* parked as failed and emit `merger:transient-failure-budget-exhausted`
|
||||
* once for diagnostic visibility.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false`, no `requeueForAutoMerge`
|
||||
* callback is wired, or global/engine pause is active.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without a per-task `autoMerge: true` override). No-op when no
|
||||
* `requeueForAutoMerge` callback is wired or global/engine pause is active.
|
||||
*
|
||||
* @returns Number of tasks recovered
|
||||
*/
|
||||
@@ -5496,12 +5515,12 @@ export class SelfHealingManager {
|
||||
if (!requeue) return 0;
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.autoMerge === false) return 0;
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const slim = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = slim.filter((t) =>
|
||||
t.column === "in-review"
|
||||
&& allowsAutoMergeProcessing(t, settings)
|
||||
&& t.status === "failed"
|
||||
&& (t.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES
|
||||
&& typeof t.error === "string"
|
||||
@@ -5642,13 +5661,13 @@ export class SelfHealingManager {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
const timeoutMs = settings.taskStuckTimeoutMs;
|
||||
if (!timeoutMs || timeoutMs <= 0) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = tasks.filter((task) =>
|
||||
task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
!task.paused &&
|
||||
Boolean(task.status && ACTIVE_MERGE_STATUSES.has(task.status)) &&
|
||||
this.isPastInterruptedMergeGrace(task, timeoutMs),
|
||||
@@ -5956,20 +5975,21 @@ export class SelfHealingManager {
|
||||
* but a later transition failed or another process moved the task before the
|
||||
* final `in-review` → `done` update completed.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
* @returns Number of tasks recovered
|
||||
*/
|
||||
async recoverMergedReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
|
||||
const mergedButNotDone = tasks.filter((t) =>
|
||||
!t.deletedAt &&
|
||||
t.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(t, settings) &&
|
||||
t.mergeDetails?.mergeConfirmed === true,
|
||||
);
|
||||
|
||||
@@ -6087,14 +6107,14 @@ export class SelfHealingManager {
|
||||
* When the no-landed predicate fails, emits `task:stuck-merge-deadlock-no-action` and skips lifecycle mutation.
|
||||
*/
|
||||
/**
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async recoverStuckMergeDeadlocks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const now = Date.now();
|
||||
const inReview = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const triage = await this.store.listTasks({ column: "triage", slim: true });
|
||||
@@ -6117,6 +6137,7 @@ export class SelfHealingManager {
|
||||
(dep) => dep.column === "triage" || dep.column === "todo",
|
||||
);
|
||||
return task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
!task.paused &&
|
||||
task.status === "failed" &&
|
||||
(task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES &&
|
||||
@@ -6278,18 +6299,19 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async recoverOrphanOnlyScopeViolations(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = tasks.filter((task) =>
|
||||
task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
task.status === "failed" &&
|
||||
task.scopeOverride !== true &&
|
||||
task.mergeDetails?.mergeConfirmed !== true &&
|
||||
@@ -6442,19 +6464,20 @@ export class SelfHealingManager {
|
||||
*
|
||||
* Idempotency: recovered tasks are moved to `done`, status/error are cleared,
|
||||
* and mergeRetries reset to 0, so subsequent sweeps will not match them.
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async recoverAlreadyMergedReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = tasks.filter((task) =>
|
||||
!task.deletedAt &&
|
||||
task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
task.status === "failed" &&
|
||||
(task.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES &&
|
||||
task.mergeDetails?.mergeConfirmed !== true &&
|
||||
@@ -6591,19 +6614,20 @@ export class SelfHealingManager {
|
||||
* Recover completed in-review tasks wedged as failed only because a post-done
|
||||
* session continuation hit a non-continuable signature.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async recoverPostDoneNonContinuableWedge(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: false });
|
||||
let recovered = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.column !== "in-review" || task.deletedAt) continue;
|
||||
if (!allowsAutoMergeProcessing(task, settings)) continue;
|
||||
if (task.paused || task.userPaused) continue;
|
||||
if (task.status !== "failed") continue;
|
||||
if (this.options.isTaskActive?.(task.id)) continue;
|
||||
@@ -6664,18 +6688,19 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async recoverCompletionHandoffLimbo(): Promise<void> {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return;
|
||||
if (settings.autoMerge === false) return;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: false });
|
||||
const now = Date.now();
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.column !== "in-review" || task.paused) continue;
|
||||
if (!allowsAutoMergeProcessing(task, settings)) continue;
|
||||
if (task.status != null || task.mergeDetails != null || task.review != null || task.reviewState != null) continue;
|
||||
if (this.options.isTaskActive?.(task.id)) continue;
|
||||
if (getTaskMergeBlocker(task) !== undefined) continue;
|
||||
@@ -6889,28 +6914,34 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async recoverForeignOnlyContaminatedInReviewTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const inReview = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const inProgress = await this.store.listTasks({ column: "in-progress", slim: true });
|
||||
const candidates = [
|
||||
...inReview.filter((task) =>
|
||||
task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
Boolean(task.branch) &&
|
||||
Boolean(task.worktree) &&
|
||||
task.mergeDetails?.mergeConfirmed !== true &&
|
||||
!task.userPaused &&
|
||||
!executingIds.has(task.id),
|
||||
),
|
||||
// The paused in-progress contamination branch is gated per-task too:
|
||||
// pre-existing behavior kept this sweep fully inert in manual-review
|
||||
// projects (mirroring the FN-5704 reclaim contract), so override-less
|
||||
// tasks stay untouched while explicit autoMerge:true tasks recover.
|
||||
...inProgress.filter((task) =>
|
||||
task.column === "in-progress" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
task.paused === true &&
|
||||
(task.pausedReason === "branch-cross-contamination" || task.pausedReason === "branch-conflict-unrecoverable") &&
|
||||
Boolean(task.branch) &&
|
||||
@@ -7730,18 +7761,19 @@ export class SelfHealingManager {
|
||||
* `restart-recovery-coordinator.ts`.
|
||||
* We clear stale worktree metadata and failure state, keep step progress and
|
||||
* retry counters, then requeue to todo for a clean retry.
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
*/
|
||||
async recoverMissingWorktreeReviewFailures(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
const candidates = tasks.filter((task) =>
|
||||
isRecoverableMissingWorktreeReviewFailureWithProgress(task)
|
||||
|| isRecoverableMissingWorktreeReviewFailureNoProgress(task),
|
||||
allowsAutoMergeProcessing(task, settings)
|
||||
&& (isRecoverableMissingWorktreeReviewFailureWithProgress(task)
|
||||
|| isRecoverableMissingWorktreeReviewFailureNoProgress(task)),
|
||||
);
|
||||
|
||||
if (candidates.length === 0) return 0;
|
||||
@@ -7813,19 +7845,20 @@ export class SelfHealingManager {
|
||||
* - `recoverNoProgressNoTaskDoneFailures`: `in-progress` with zero progress → clean requeue.
|
||||
* - This one: `in-review` with partial progress → bounded requeue preserving work.
|
||||
*
|
||||
* No-op when `settings.autoMerge === false` — PR-based review flow owns lifecycle until human merge.
|
||||
* Skips tasks not eligible for auto-merge processing (global `autoMerge`
|
||||
* off without an explicit per-task `autoMerge: true` override) — PR-based
|
||||
* review flow owns lifecycle until human merge.
|
||||
* @returns Number of tasks requeued for retry
|
||||
*/
|
||||
async recoverPartialProgressNoTaskDoneFailures(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
if (settings.autoMerge === false) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
|
||||
const candidates = tasks.filter((task) =>
|
||||
task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
task.status === "failed" &&
|
||||
isNoTaskDoneFailure(task) &&
|
||||
!task.paused &&
|
||||
|
||||
Reference in New Issue
Block a user