feat(FN-4763): complete Step 7 — finalize reclaim wiring and docs
Fusion-Task-Id: FN-4763 Fusion-Task-Lineage: b3628e63-682b-4a39-9a7d-2c96278c5366
This commit is contained in:
committed by
gsxdsm
parent
412ee29a24
commit
bba945c8be
5
.changeset/fn-4763-pr-conflict-detection.md
Normal file
5
.changeset/fn-4763-pr-conflict-detection.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Detect PR merge conflicts via gh PR refresh and route affected tasks through the existing self-healing branch-reclaim path. Adds an optional `mergeable` field on `PrInfo`, a "Retry conflict reclaim" affordance in the PR section, and a new `POST /api/tasks/:id/pr/reclaim-conflict` endpoint.
|
||||||
@@ -174,6 +174,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
|
|||||||
- **In-review stall deadlock**: identical stalls (same code + reason) repeated past `inReviewStallDeadlockThreshold` (default 3) auto-pause with `pausedReason: "in-review-stall-deadlock"` and `status: "failed"`.
|
- **In-review stall deadlock**: identical stalls (same code + reason) repeated past `inReviewStallDeadlockThreshold` (default 3) auto-pause with `pausedReason: "in-review-stall-deadlock"` and `status: "failed"`.
|
||||||
- **Restart recovery**: `RestartRecoveryCoordinator` classifies interrupted `in-progress` runs. Unusable-worktree session-start failures (`missing`, `incomplete`, `unregistered git worktree`) are recoverable; retries are capped at `MAX_WORKTREE_SESSION_RETRIES=3` before escalating.
|
- **Restart recovery**: `RestartRecoveryCoordinator` classifies interrupted `in-progress` runs. Unusable-worktree session-start failures (`missing`, `incomplete`, `unregistered git worktree`) are recoverable; retries are capped at `MAX_WORKTREE_SESSION_RETRIES=3` before escalating.
|
||||||
- **Task title/ID drift (FN-4898)**: active and archived title writes normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts`. Lineage is preserved in `sourceParentTaskId` / description markers, not title embeds.
|
- **Task title/ID drift (FN-4898)**: active and archived title writes normalize foreign embedded `FN-NNN` tokens via `packages/core/src/task-title-id-drift.ts`. Lineage is preserved in `sourceParentTaskId` / description markers, not title embeds.
|
||||||
|
- **PR-conflict reclaim wiring (FN-4763)**: GitHub PR refresh now persists normalized `prInfo.mergeable` conflict state and, when conflicting, funnels tasks into self-healing’s existing reclaim machinery (`reclaimPrConflictForTask` / `reclaim-pr-conflicts` stage) so branch-conflict handling stays centralized with existing `inspectBranchConflict` outcomes and unrecoverable pause semantics.
|
||||||
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native.
|
- **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level reclaim and orphan rescue stay native.
|
||||||
|
|
||||||
## Engine Process Rules
|
## Engine Process Rules
|
||||||
|
|||||||
@@ -3333,12 +3333,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
|
|
||||||
// Trigger background refresh if stale (don't await, let it run)
|
// Trigger background refresh if stale (don't await, let it run)
|
||||||
if (isStale) {
|
if (isStale) {
|
||||||
const selfHealingManager = engine?.getSelfHealingManager?.();
|
refreshPrInBackground(scopedStore, task.id, task.prInfo, githubToken);
|
||||||
refreshPrInBackground(scopedStore, task.id, task.prInfo, githubToken, {
|
|
||||||
onConflictDetected: async (taskId) => {
|
|
||||||
await selfHealingManager?.reclaimPrConflictForTask(taskId);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -3427,7 +3422,11 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
|
|
||||||
let conflictReclaimQueued = false;
|
let conflictReclaimQueued = false;
|
||||||
if (prInfo.mergeable === "conflicting" && task.branch && task.worktree) {
|
if (prInfo.mergeable === "conflicting" && task.branch && task.worktree) {
|
||||||
const selfHealingManager = engine?.getSelfHealingManager?.();
|
const selfHealingManager =
|
||||||
|
(engine as { getSelfHealingManager?: () => { reclaimPrConflictForTask: (taskId: string) => Promise<unknown> } } | undefined)?.getSelfHealingManager?.() ??
|
||||||
|
(engine as { getRuntime?: () => { getSelfHealingManager?: () => { reclaimPrConflictForTask: (taskId: string) => Promise<unknown> } } } | undefined)
|
||||||
|
?.getRuntime?.()
|
||||||
|
?.getSelfHealingManager?.();
|
||||||
if (selfHealingManager) {
|
if (selfHealingManager) {
|
||||||
await selfHealingManager.reclaimPrConflictForTask(task.id);
|
await selfHealingManager.reclaimPrConflictForTask(task.id);
|
||||||
conflictReclaimQueued = true;
|
conflictReclaimQueued = true;
|
||||||
@@ -3483,7 +3482,11 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
|||||||
throw conflict("Task has no branch/worktree to reclaim");
|
throw conflict("Task has no branch/worktree to reclaim");
|
||||||
}
|
}
|
||||||
|
|
||||||
const selfHealingManager = engine?.getSelfHealingManager?.();
|
const selfHealingManager =
|
||||||
|
(engine as { getSelfHealingManager?: () => { reclaimPrConflictForTask: (taskId: string) => Promise<unknown> } } | undefined)?.getSelfHealingManager?.() ??
|
||||||
|
(engine as { getRuntime?: () => { getSelfHealingManager?: () => { reclaimPrConflictForTask: (taskId: string) => Promise<unknown> } } } | undefined)
|
||||||
|
?.getRuntime?.()
|
||||||
|
?.getSelfHealingManager?.();
|
||||||
if (!selfHealingManager) {
|
if (!selfHealingManager) {
|
||||||
return res.json({ queued: false, reason: "engine-unavailable" });
|
return res.json({ queued: false, reason: "engine-unavailable" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ function makeStore(
|
|||||||
if (column === "in-progress") return [task];
|
if (column === "in-progress") return [task];
|
||||||
return [];
|
return [];
|
||||||
}),
|
}),
|
||||||
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => (task ? Object.assign(task, updates) : null)), moveTask: vi.fn(async (_id: string, column: Task["column"]) => {
|
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => (task ? Object.assign(task, updates) : null)),
|
||||||
|
moveTask: vi.fn(async (_id: string, column: Task["column"]) => {
|
||||||
|
if (!task) return null;
|
||||||
task.column = column;
|
task.column = column;
|
||||||
return task;
|
return task;
|
||||||
}),
|
}),
|
||||||
@@ -161,7 +163,7 @@ describe("SelfHealingManager.reclaimPrConflictForTask", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips when branch or worktree is missing", async () => {
|
it("skips when branch or worktree is missing", async () => {
|
||||||
const task = makeTask({ branch: null });
|
const task = makeTask({ branch: undefined });
|
||||||
const store = makeStore(task);
|
const store = makeStore(task);
|
||||||
const manager = new SelfHealingManager(store as any, { rootDir: "/tmp/test" } as any);
|
const manager = new SelfHealingManager(store as any, { rootDir: "/tmp/test" } as any);
|
||||||
const result = await manager.reclaimPrConflictForTask(task.id);
|
const result = await manager.reclaimPrConflictForTask(task.id);
|
||||||
|
|||||||
@@ -1356,7 +1356,7 @@ export class SelfHealingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reclaimPrConflictForTask(taskId: string): Promise<{ outcome: "reclaimed" | "stale-resolved" | "tip-already-merged" | "paused-unrecoverable" | "skipped"; reason?: string }> {
|
async reclaimPrConflictForTask(taskId: string): Promise<{ outcome: "reclaimed" | "stale-resolved" | "tip-already-merged" | "paused-unrecoverable" | "skipped"; reason?: string }> {
|
||||||
const task = this.store.getTask(taskId);
|
const task = await this.store.getTask(taskId);
|
||||||
if (!task) return { outcome: "skipped", reason: "task-not-found" };
|
if (!task) return { outcome: "skipped", reason: "task-not-found" };
|
||||||
|
|
||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
|
|||||||
Reference in New Issue
Block a user