From 08d1f091070f0ebab46834e33a0646c47272dc05 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 15:11:22 -0700 Subject: [PATCH 01/11] FN-6796: preserve benign in-review pause aborts Preserve completed in-review tasks when benign pause/resume aborts surface after executor handoff. - Detect non-user hard-cancel pause aborts on clean completed in-review rows and clear the transient abort marker without moving the task. - Let self-healing recover persisted safe in-review pause-abort parks in place while keeping hard-cancel, pause, autoMerge:false, terminal merge, and live-execution guards intact. - Cover executor and self-healing recovery paths with regression tests, document the audit metadata, and add a patch changeset. Files changed: .../fn-6796-pause-resume-in-review-recovery.md | 5 ++ AGENTS.md | 1 + docs/architecture.md | 2 + .../engine/src/__tests__/executor-recovery.test.ts | 97 +++++++++++++++++++++- .../active-worktree-removal-liveness.test.ts | 2 +- .../self-healing-paused-abort-recovery.test.ts | 90 +++++++++++++++++++- packages/engine/src/executor.ts | 35 ++++++++ packages/engine/src/self-healing.ts | 38 +++++++-- 8 files changed, 257 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-6796 Fusion-Task-Lineage: d2fe6c6d-c118-4f2f-b19e-58e6f1b29384 --- ...fn-6796-pause-resume-in-review-recovery.md | 5 + AGENTS.md | 1 + docs/architecture.md | 2 + .../src/__tests__/executor-recovery.test.ts | 97 ++++++++++++++++++- .../active-worktree-removal-liveness.test.ts | 2 +- ...self-healing-paused-abort-recovery.test.ts | 90 ++++++++++++++++- packages/engine/src/executor.ts | 35 +++++++ packages/engine/src/self-healing.ts | 38 ++++++-- 8 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 .changeset/fn-6796-pause-resume-in-review-recovery.md diff --git a/.changeset/fn-6796-pause-resume-in-review-recovery.md b/.changeset/fn-6796-pause-resume-in-review-recovery.md new file mode 100644 index 0000000000..82028c2c96 --- /dev/null +++ b/.changeset/fn-6796-pause-resume-in-review-recovery.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Recover benign in-review pause/resume abort parks without requiring operator intervention while preserving hard-cancel, pause, and terminal merge safeguards. diff --git a/AGENTS.md b/AGENTS.md index a57f0f4c3b..f68a9512d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,6 +193,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move. - FN-6736: self-healing emits `task:reclaim-phantom-executor-binding` when it proves an in-memory executor-active binding is stale, clears the binding, and requeues the in-progress task with worktree/progress preserved. - FN-6783: task-store open and self-healing housekeeping emit `task:reconcile-orphaned-task-dir` when they non-destructively re-import a valid live `.fusion/tasks/{ID}/task.json` directory that has no task row anywhere, preserving soft-deleted/archived/tombstoned IDs. +- FN-6782/FN-6796: self-healing emits `task:auto-recover-paused-abort-park` when it clears a benign pause-abort operator park, requeueing safe `todo`/`in-progress` rows or preserving a clean auto-merge-eligible `in-review` row for review progression. - FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move. diff --git a/docs/architecture.md b/docs/architecture.md index d6ce2180de..92f1dd93a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -679,6 +679,7 @@ Runtime action-gate flow (v1): - Worktrees-dir sweeps that list direct children of `` (pool idle scan, orphan cleanup/reap, self-healing unregistered-orphan reap, and cap enforcement) must exclude the `.ai-merge` container by name; those one-level sweeps never inspect or recycle clean rooms beneath it. Batch 1 sweeps stale AI merge clean-room worktrees under the new `/.ai-merge/` root and still scans legacy `.fusion/ai-merge/` plus legacy `tmpdir()` locations for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so the dedicated periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example SQLite busy/parse errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force ` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle. - `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`. + - `recoverPausedAbortFailures()` clears executor pause/resume abort parks only when the durable row is safe to recover. `todo`/`in-progress` rows are requeued for normal scheduling, while clean `in-review` rows (completed steps, not paused/user-paused/executing, auto-merge eligible, no confirmed or terminal merge evidence) have `status`/`error` cleared in place so review progression can continue. User hard-cancel, global/user pause, `autoMerge:false`, terminal merge, and live-execution guards remain operator-actionable. Successful recovery emits `task:auto-recover-paused-abort-park` with `preservedInReview` metadata. - `reattach-orphaned-assigned-executions` is a forward-resume safety net for durable-agent assignments. During startup recovery and periodic maintenance, after orphaned-agent and stale-heartbeat-run repairs, self-healing finds `in-progress` tasks with an `assignedAgentId` whose agent has no active heartbeat run and no active executor session after the orphan grace window. It re-dispatches in place via `executor.resumeTaskForAgent(agentId)` (the same seam used by clean `HeartbeatMonitor.onRunCompleted` and guarded by executor double-execution checks), emits `task:reattach-orphaned-execution`, and never moves the task backward. This complements engine-start `executor.resumeOrphaned()` and leaves unassigned/role-based execution recovery to the existing startup/limbo/stuck-task paths. - Mission validation has a dedicated stale-run reaper: startup recovery and Batch 2 maintenance call `reapStaleMissionValidatorRuns()` when wired by the runtime, using `VALIDATOR_RUN_STALE_MAX_AGE_MS` (currently 6 hours). The sweep terminates ownerless `mission_validator_runs.status='running'` rows as `error`, writes the reap reason into `summary`, leaves `lastValidatorRunId` pointing at the now-terminal run, and emits run-audit telemetry with `mutationType: "mission:validator-run-reaped"` plus `runId`/`featureId`/`missionId`/`triggerType`/`elapsedMs` metadata. Active mission features move to `loopState="needs_fix"` + `lastValidatorStatus="error"` unless their parent mission is already `complete`/`archived`. @@ -730,6 +731,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/ ### Observability and reflection - `AgentLogger` (`agent-logger.ts`) — structured per-agent run logging - `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem) + - FN-6782/FN-6796: `task:auto-recover-paused-abort-park` records self-healing recovery of pause-abort operator parks. Metadata includes the source column and whether recovery preserved a clean `in-review` row instead of requeueing to `todo`. - FN-4956: Layer 3 merge-conflict arbitration now scope-partitions conflicted files before AI resolution. Out-of-scope conflicts are deterministically resolved to the integration branch (`git checkout --ours`) and unstaged, while only in-scope conflicts flow to AI. Integration branch defaults are resolved via `resolveIntegrationBranch(rootDir, settings)`. Audit events: `merge:layer3:foreign-file-skipped` and `merge:layer3:scope-override-bypass`. - FN-5655 goal anchoring observability adds `database`-domain mutation types `goal:injection-applied`, `goal:injection-skipped`, and `goal:retrieval-invoked` so Slice 2 cite-rate tracking has a prompt-independent signal. Metadata uses counts/IDs only (`count`, `lane`, `toolName`, optional `truncated`/`reason`/`notFound`) and never stores prompt bodies or goal titles/descriptions. These events surface through `GET /api/agents/:id/runs/:runId/audit` and support the existing `startTime`/`endTime` filters. diff --git a/packages/engine/src/__tests__/executor-recovery.test.ts b/packages/engine/src/__tests__/executor-recovery.test.ts index b3cffb439a..ae9e39343b 100644 --- a/packages/engine/src/__tests__/executor-recovery.test.ts +++ b/packages/engine/src/__tests__/executor-recovery.test.ts @@ -1200,7 +1200,102 @@ describe("TaskExecutor bounded recovery retries", () => { expect(store.handoffToReview).not.toHaveBeenCalled(); }); - it("surfaces genuine hard-cancel pausedAborted in-review graph exits as workflow failures", async () => { + it("preserves clean completed in-review rows after benign engine-restart hard-cancel provenance without finalize log", async () => { + const store = createMockStore(); + const steps = [ + { name: "Preflight", status: "done" }, + { name: "Implement", status: "done" }, + ]; + const task = { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + status: undefined, + dependencies: [], + steps, + currentStep: 1, + // FNXC:WorkflowLifecycle 2026-06-20-00:00: + // FN-6796 symptom coverage must omit the paused-after-completion finalize log so this exercises the benign in-review pause-abort classifier, not the older alreadyFinalizedToReview suppression path. + log: [{ timestamp: new Date().toISOString(), action: "Normal review handoff without paused-completion marker" }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; + store.getTask.mockResolvedValue({ + ...task, + column: "in-review", + paused: false, + userPaused: false, + status: null, + error: null, + }); + const executor = new TaskExecutor(store, "/tmp/test", {}); + (executor as any).markPausedAborted("FN-001", "hard-cancel"); + + await (executor as any).handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + visitedNodeIds: ["execute"], + }); + + const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n"); + expect(messages).toContain("Workflow graph run ended during engine pause/resume while already in-review — benign, in-review state preserved"); + expect(messages).not.toContain("Workflow graph failure surfaced after paused engine abort during pause/resume"); + expect(messages).not.toContain("operator action required"); + expect((executor as any).pausedAborted.has("FN-001")).toBe(false); + expect(store.updateTask).not.toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ status: "failed" }), + expect.anything(), + ); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.handoffToReview).not.toHaveBeenCalled(); + }); + + it("surfaces user hard-cancel in-review graph exits with completed steps as workflow failures", async () => { + const store = createMockStore(); + const steps = [ + { name: "Preflight", status: "done" }, + { name: "Implement", status: "done" }, + ]; + const task = { + id: "FN-001", + title: "Test", + description: "Test", + column: "in-progress", + status: undefined, + dependencies: [], + steps, + currentStep: 1, + log: [{ timestamp: new Date().toISOString(), action: "Normal review handoff without paused-completion marker" }], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; + store.getTask.mockResolvedValue({ + ...task, + column: "in-review", + paused: false, + userPaused: false, + status: null, + error: null, + }); + const executor = new TaskExecutor(store, "/tmp/test", {}); + await (executor as any).awaitAbortInFlightTaskWork("FN-001", "user move in-progress to todo", { userCanceled: true }); + + await (executor as any).handleGraphFailure(task, { + disposition: "failed", + outcome: "failure", + visitedNodeIds: ["execute"], + }); + + const expectedMessage = "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; + expect((executor as any).userCanceledTaskIds.has("FN-001")).toBe(true); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(store.handoffToReview).not.toHaveBeenCalled(); + }); + + it("surfaces incomplete hard-cancel pausedAborted in-review graph exits as workflow failures", async () => { const store = createMockStore(); const steps = [ { name: "Preflight", status: "pending" }, diff --git a/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts b/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts index dfc73354d1..4889abc25d 100644 --- a/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts @@ -217,7 +217,7 @@ describe("FN-4811: active worktree removal liveness gate", () => { expect(execCalls.some((c) => c.includes('git branch -D "fusion/fn-9999"'))).toBe(true); // Must have logged the stale-path cleanup outcome — NOT the generic failure log. const logCalls = store.logEntry.mock.calls.map((c: any[]) => String(c[1] ?? "")); - expect(logCalls.some((m: string) => m.includes("Cleaned up stale conflicting worktree admin entry"))).toBe(true); + expect(logCalls.some((m: string) => m.includes("Cleaned up stale conflicting worktree"))).toBe(true); expect(logCalls.some((m: string) => m === "Failed to clean up conflicting worktree")).toBe(false); }); }); diff --git a/packages/engine/src/__tests__/self-healing-paused-abort-recovery.test.ts b/packages/engine/src/__tests__/self-healing-paused-abort-recovery.test.ts index 4ff04e103c..e89662d5b7 100644 --- a/packages/engine/src/__tests__/self-healing-paused-abort-recovery.test.ts +++ b/packages/engine/src/__tests__/self-healing-paused-abort-recovery.test.ts @@ -29,6 +29,9 @@ import type { Settings, Task, TaskStore } from "@fusion/core"; const PARK_ERROR = "Workflow graph failure surfaced after paused engine abort during pause/resume in 'todo' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; +const IN_REVIEW_PARK_ERROR = + "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task"; +const DONE_STEPS = [{ status: "done" }, { status: "done" }]; function createMockStore(tasks: Task[]): TaskStore & EventEmitter { const emitter = new EventEmitter(); @@ -114,11 +117,45 @@ describe("recoverPausedAbortFailures", () => { ); }); - it("skips paused, executing, in-review, and non-pause-abort failures", async () => { + it("clears a completed in-review pause-abort park without moving it backward", async () => { + const store = createMockStore([parkTask({ + id: "FN-7002", + column: "in-review", + error: IN_REVIEW_PARK_ERROR, + steps: DONE_STEPS, + autoMerge: true, + })]); + const clearBinding = vi.fn().mockReturnValue(true); + const manager = new SelfHealingManager(store, { + rootDir: "/tmp/test-project", + getExecutingTaskIds: () => new Set(), + clearPhantomExecutorBinding: clearBinding as (taskId: string) => boolean | void, + }); + + const recovered = await manager.recoverPausedAbortFailures(); + + expect(recovered).toBe(1); + expect(store.updateTask).toHaveBeenCalledWith("FN-7002", { status: null, error: null }); + expect(store.moveTask).not.toHaveBeenCalled(); + expect(clearBinding).toHaveBeenCalledWith("FN-7002"); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-7002", + "Auto-recovered: in-review pause-abort park cleared — preserved for normal review progression", + ); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + mutationType: "task:auto-recover-paused-abort-park", + target: "FN-7002", + metadata: { fromColumn: "in-review", preservedInReview: true }, + }), + ); + }); + + it("skips paused, executing, incomplete in-review, and non-pause-abort failures", async () => { const store = createMockStore([ parkTask({ id: "FN-A", paused: true }), parkTask({ id: "FN-B", column: "in-progress" }), // executing (below) - parkTask({ id: "FN-C", column: "in-review" }), // in-review park left for operator + parkTask({ id: "FN-C", column: "in-review", error: IN_REVIEW_PARK_ERROR }), // incomplete in-review park left for operator parkTask({ id: "FN-D", error: "some other failure", status: "failed" }), ]); const manager = new SelfHealingManager(store, { @@ -132,6 +169,55 @@ describe("recoverPausedAbortFailures", () => { expect(store.updateTask).not.toHaveBeenCalled(); }); + it("leaves guarded in-review pause-abort parks untouched", async () => { + const candidates = [ + parkTask({ id: "FN-U", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, userPaused: true, autoMerge: true }), + parkTask({ id: "FN-X", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, autoMerge: true }), + parkTask({ id: "FN-M", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, autoMerge: true, mergeDetails: { mergeConfirmed: true } as any }), + parkTask({ id: "FN-T", column: "in-review", error: `${IN_REVIEW_PARK_ERROR} merge-conflict`, steps: DONE_STEPS, autoMerge: true }), + parkTask({ id: "FN-A", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, autoMerge: undefined }), + ]; + const store = createMockStore(candidates); + (store.getSettings as ReturnType).mockResolvedValue({ + autoMerge: false, + globalPause: false, + enginePaused: false, + maintenanceIntervalMs: 0, + } as unknown as Settings); + const manager = new SelfHealingManager(store, { + rootDir: "/tmp/test-project", + getExecutingTaskIds: () => new Set(["FN-X"]), + }); + + const recovered = await manager.recoverPausedAbortFailures(); + + expect(recovered).toBe(0); + expect(store.updateTask).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalled(); + }); + + it("revalidates in-review recovery against fresh state before clearing the park", async () => { + const initial = parkTask({ + id: "FN-STALE", + column: "in-review", + error: IN_REVIEW_PARK_ERROR, + steps: DONE_STEPS, + autoMerge: true, + }); + const store = createMockStore([initial]); + (store.getTask as ReturnType).mockResolvedValue({ ...initial, paused: true }); + const manager = new SelfHealingManager(store, { + rootDir: "/tmp/test-project", + getExecutingTaskIds: () => new Set(), + }); + + const recovered = await manager.recoverPausedAbortFailures(); + + expect(recovered).toBe(0); + expect(store.updateTask).not.toHaveBeenCalled(); + expect(store.moveTask).not.toHaveBeenCalled(); + }); + // FNXC:WorkflowLifecycle greptile P1 (PR #1687): the method self-guards on // global/engine pause at its own entry, so calling it directly (test/API path) // while the operator has frozen the board must be a no-op. diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index cf1bf6ccca..9540362a02 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -6529,6 +6529,32 @@ export class TaskExecutor { return true; } + private isBenignInReviewPauseAbort( + live: TaskDetail, + result: WorkflowGraphTaskRunResult, + abortProvenance: "global-pause" | "merge-seam" | "hard-cancel" | "completion-finalize" | undefined, + pausedAborted: boolean, + userCanceled: boolean, + ): boolean { + /* + FNXC:WorkflowLifecycle 2026-06-20-00:00: + FN-6796: an engine restart/pause-resume abort reaches graph-failure handling as `hard-cancel` provenance even when no user canceled the task. A clean completed `in-review` row in that shape is already handed off for review and must not be stranded with the operator-action pause-abort marker; the discriminator is the in-memory `userCanceledTaskIds` set plus the resting column and clean row state, while global/user pause, merge-seam, terminal merge values, merge-confirmed partial landings, and pre-existing status/error still park exactly as before. + */ + if (!pausedAborted) return false; + if (abortProvenance !== "hard-cancel") return false; + if (userCanceled) return false; + if (live.column !== "in-review") return false; + if (live.userPaused === true) return false; + if (live.status != null || live.error != null) return false; + if (live.mergeDetails?.mergeConfirmed === true) return false; + if (this.isTerminalMergeGraphFailureValue(this.graphFailureValue(result))) return false; + const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1]; + if (this.isMergeGraphFailure(failedNode)) return false; + if (live.steps.length === 0) return false; + if (!live.steps.every((step) => step.status === "done" || step.status === "skipped")) return false; + return true; + } + private async routeGraphMergeFailureToRetry( live: TaskDetail, result: WorkflowGraphTaskRunResult, @@ -6622,6 +6648,15 @@ export class TaskExecutor { return; } } + if (genuinePauseAbort && this.isBenignInReviewPauseAbort(live, result, abortProvenance, pausedAborted, this.userCanceledTaskIds.has(task.id))) { + this.clearPausedAborted(task.id); + this.activeWorktrees.delete(task.id); + const inReviewBenign = "Workflow graph run ended during engine pause/resume while already in-review — benign, in-review state preserved"; + executorLog.log(`${task.id}: ${inReviewBenign}`); + await this.store.logEntry(task.id, inReviewBenign, undefined, this.getRunContextFor(task.id)); + await this.persistTokenUsage(task.id); + return; + } if (genuinePauseAbort) { /* FNXC:WorkflowLifecycle 2026-06-15-01:45: diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 546d5f6584..d65c4ac997 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -8132,16 +8132,34 @@ export class SelfHealingManager { typeof t.error === "string" && t.error.includes(PAUSE_ABORT_PARK_OPERATOR_MARKER) && t.error.includes(PAUSE_ABORT_PARK_ERROR_MARKER); + const isTerminalMergePark = (t: Task): boolean => { + const text = typeof t.error === "string" ? t.error.toLowerCase() : ""; + return text.includes("conflict") + || text.includes("contamination") + || text.includes("foreign") + || text.includes("retry-exhausted") + || text.includes("retries exhausted") + || text.includes("max retries"); + }; + const isRecoverableInReviewPauseAbortPark = (t: Task): boolean => { + /* + FNXC:WorkflowLifecycle 2026-06-20-00:00: + FN-6796 defense-in-depth: executor memory that distinguishes benign engine aborts from user hard-cancel is gone after restart, so self-healing may recover only persisted clean `in-review` pause-abort parks: non-paused, not executing, auto-merge eligible, completed steps, no terminal/confirmed merge evidence. User hard-cancel rows rest in `todo`; global/user pauses and autoMerge:false review rows remain operator-controlled. + */ + return t.column === "in-review" + && allowsAutoMergeProcessing(t, settings) + && t.mergeDetails?.mergeConfirmed !== true + && !isTerminalMergePark(t) + && t.steps.length > 0 + && t.steps.every((step) => step.status === "done" || step.status === "skipped"); + }; const parked = tasks.filter((t) => isPausedAbortPark(t) && !t.paused && !t.userPaused && !executingIds.has(t.id) && - // Only recover columns that are safe to requeue. done/archived parks are - // terminal and in-review parks may carry merge state — leave those for - // the existing review recoverers / operator inspection. - (t.column === "todo" || t.column === "in-progress"), + (t.column === "todo" || t.column === "in-progress" || isRecoverableInReviewPauseAbortPark(t)), ); if (parked.length === 0) return 0; @@ -8165,13 +8183,13 @@ export class SelfHealingManager { fresh.paused || fresh.userPaused || latestExecutingIds.has(fresh.id) || - !(fresh.column === "todo" || fresh.column === "in-progress") + !(fresh.column === "todo" || fresh.column === "in-progress" || isRecoverableInReviewPauseAbortPark(fresh)) ) { continue; } await this.store.updateTask(task.id, { status: null, error: null }); - if (fresh.column !== "todo") { + if (fresh.column !== "todo" && fresh.column !== "in-review") { await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", @@ -8187,7 +8205,9 @@ export class SelfHealingManager { await this.store.logEntry( task.id, - "Auto-recovered: pause-abort park cleared — requeued for normal scheduling", + fresh.column === "in-review" + ? "Auto-recovered: in-review pause-abort park cleared — preserved for normal review progression" + : "Auto-recovered: pause-abort park cleared — requeued for normal scheduling", ); // FNXC:WorkflowLifecycle 2026-06-20-00:00: audit emission is strictly // best-effort — an audit throw AFTER the successful state mutation must @@ -8201,7 +8221,7 @@ export class SelfHealingManager { domain: "database", mutationType: "task:auto-recover-paused-abort-park", target: task.id, - metadata: { fromColumn: fresh.column }, + metadata: { fromColumn: fresh.column, preservedInReview: fresh.column === "in-review" }, }); } catch (auditErr: unknown) { log.warn(`Pause-abort park audit emission failed for ${task.id}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`); @@ -8214,7 +8234,7 @@ export class SelfHealingManager { } if (recovered > 0) { - log.log(`Recovered ${recovered} pause-abort park(s) → requeued to todo`); + log.log(`Recovered ${recovered} pause-abort park(s) → requeued to todo or preserved in review`); } return recovered; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); From c808177979e9e2514f5e388c184955754afa65c6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 15:42:16 -0700 Subject: [PATCH 02/11] FN-6776: prevent workflow board legacy flash Prevent workflow-enabled board surfaces from flashing the legacy layout during first paint. - Hydrate Board and ListView workflow metadata from a per-project session cache before async revalidation. - Gate legacy board/list rendering behind settings and workflow-lane metadata readiness, with neutral skeleton states for cold or empty workflow loads. - Add regression coverage for no-legacy-flash behavior and cache validation, plus docs and changeset notes. Files changed: .changeset/fn-6776-board-flash.md | 5 + docs/dashboard-guide.md | 1 + packages/dashboard/app/App.tsx | 4 + packages/dashboard/app/components/Board.css | 69 +++++ packages/dashboard/app/components/Board.tsx | 53 +++- packages/dashboard/app/components/ListView.css | 53 ++++ packages/dashboard/app/components/ListView.tsx | 52 +++- .../__tests__/board-no-legacy-flash.test.tsx | 326 +++++++++++++++++++++ .../utils/__tests__/boardWorkflowsCache.test.ts | 71 +++++ .../dashboard/app/utils/boardWorkflowsCache.ts | 49 ++++ 10 files changed, 676 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-6776 Fusion-Task-Lineage: caa8e53f-9b6d-49a6-94ca-b694550b43ef --- .changeset/fn-6776-board-flash.md | 5 + docs/dashboard-guide.md | 1 + packages/dashboard/app/App.tsx | 4 + packages/dashboard/app/components/Board.css | 69 ++++ packages/dashboard/app/components/Board.tsx | 53 ++- .../dashboard/app/components/ListView.css | 53 +++ .../dashboard/app/components/ListView.tsx | 52 ++- .../__tests__/board-no-legacy-flash.test.tsx | 326 ++++++++++++++++++ .../__tests__/boardWorkflowsCache.test.ts | 71 ++++ .../app/utils/boardWorkflowsCache.ts | 49 +++ 10 files changed, 676 insertions(+), 7 deletions(-) create mode 100644 .changeset/fn-6776-board-flash.md create mode 100644 packages/dashboard/app/components/Board.css create mode 100644 packages/dashboard/app/components/__tests__/board-no-legacy-flash.test.tsx create mode 100644 packages/dashboard/app/utils/__tests__/boardWorkflowsCache.test.ts create mode 100644 packages/dashboard/app/utils/boardWorkflowsCache.ts diff --git a/.changeset/fn-6776-board-flash.md b/.changeset/fn-6776-board-flash.md new file mode 100644 index 0000000000..5f96f6325c --- /dev/null +++ b/.changeset/fn-6776-board-flash.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Eliminate the legacy board flash before workflow lanes load by caching per-project board workflow metadata and showing a neutral skeleton while metadata resolves. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index fcff2d93b5..374f0ac53c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -78,6 +78,7 @@ Features: - Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) - On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll. - Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger and each workflow option show compact Todo / In Progress / Done counts derived from workflow column flags, excluding archived columns. +- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. ![Board view](./screenshots/dashboard-overview.png) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index d7e7e4c431..aaee296ab6 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1849,6 +1849,8 @@ function AppInner() { prAuthAvailable={prAuthAvailable} onOpenWorkflowEditor={openWorkflowEditorWithNav} onCreateWorkflow={openCreateWorkflowWithNav} + workflowColumnsEnabled={experimentalFeatures.workflowColumns === true} + settingsLoaded={settingsLoaded} /> ); @@ -1887,6 +1889,8 @@ function AppInner() { prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} onCreateWorkflow={openCreateWorkflowWithNav} + workflowColumnsEnabled={experimentalFeatures.workflowColumns === true} + settingsLoaded={settingsLoaded} /> ); diff --git a/packages/dashboard/app/components/Board.css b/packages/dashboard/app/components/Board.css new file mode 100644 index 0000000000..aa8a71c027 --- /dev/null +++ b/packages/dashboard/app/components/Board.css @@ -0,0 +1,69 @@ +/* +FNXC:BoardWorkflows 2026-06-20-09:02: +The board needs a neutral first-paint shell whenever workflow lanes are enabled but metadata is not ready, so users never see the legacy single-lane board flash before workflow lanes load. +*/ +.board.board-workflows-skeleton { + display: flex; + align-items: stretch; + gap: var(--column-gap, var(--space-md)); + overflow-x: auto; + overflow-y: hidden; + padding: var(--board-padding, var(--space-md)); + background: var(--bg); +} + +.board-workflows-skeleton__column { + flex: 1 0 min(24rem, 85vw); + min-width: min(24rem, 85vw); + display: flex; + flex-direction: column; + gap: var(--space-md); + padding: var(--space-md); + border: 1px solid var(--border); + background: var(--surface); +} + +.board-workflows-skeleton__header, +.board-workflows-skeleton__card { + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--border) 70%, var(--surface)); + animation: board-workflows-skeleton-pulse 1.4s ease-in-out infinite; +} + +.board-workflows-skeleton__header { + block-size: var(--space-lg); + inline-size: 55%; +} + +.board-workflows-skeleton__card { + block-size: calc(var(--space-2xl) * 2); + inline-size: 100%; +} + +.board-workflows-skeleton__card--short { + inline-size: 75%; +} + +@keyframes board-workflows-skeleton-pulse { + 0%, 100% { + opacity: 0.55; + } + + 50% { + opacity: 1; + } +} + +@media (max-width: 768px) { + .board.board-workflows-skeleton { + gap: var(--space-sm); + padding: var(--space-sm); + scroll-snap-type: x proximity; + } + + .board-workflows-skeleton__column { + flex-basis: 86vw; + min-width: 86vw; + scroll-snap-align: center; + } +} diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 29fec0adb7..d64fbd5335 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -3,6 +3,7 @@ import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core"; import { sortTasksForDisplayColumn } from "./taskSorting"; import { Column } from "./Column"; import "./Lane.css"; +import "./Board.css"; import type { ToastType } from "../hooks/useToast"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -15,6 +16,7 @@ import { subscribeSse } from "../sse-bus"; import { getBoardCanDropTaskRejection } from "./boardCanDropTask"; import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; +import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; interface BoardProps { tasks: Task[]; @@ -74,6 +76,10 @@ interface BoardProps { onOpenWorkflowEditor?: (workflowId?: string) => void; /** Opens the workflow editor to create a new workflow. */ onCreateWorkflow?: () => void; + /** Already-resolved app setting for whether workflow lanes should be used. */ + workflowColumnsEnabled?: boolean; + /** Whether app settings have loaded; false gates the legacy board until the workflow flag is known. */ + settingsLoaded?: boolean; } @@ -124,7 +130,21 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap, next return true; } -export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow }: BoardProps) { +function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) { + return ( +
+ {[0, 1, 2].map((index) => ( + + ))} +
+ ); +} + +export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded }: BoardProps) { const { t } = useTranslation("app"); const [archivedCollapsed, setArchivedCollapsed] = useState(true); const archivedLoadedRef = useRef(false); @@ -330,9 +350,18 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask }, []); // ── U9 multi-lane board (flag-gated) ────────────────────────────────────── + /* + FNXC:BoardWorkflows 2026-06-20-08:58: + Workflow-columns-enabled users must never see the legacy single-lane board while board-workflows metadata is still loading. Hydrate metadata from the project-scoped session cache, reset it on project switches, and show a neutral skeleton while settings or uncached workflow metadata are unknown. + */ // Fetch board-workflows metadata. When the flag is OFF the server returns // { flagEnabled: false } and we render the legacy single-lane board below. - const [boardWorkflows, setBoardWorkflows] = useState(null); + const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false; + const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => { + const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; + return cached ? { projectId, payload: cached } : null; + }); + const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null; const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); const draggingTaskIdRef = useRef(null); @@ -344,16 +373,24 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask // when a definition's lanes / column traits change. The visibility/focus // refetch below is retained as a stopgap for missed events / reconnects. const boardWorkflowsFetchSeqRef = useRef(0); + useEffect(() => { + const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; + setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); + }, [projectId, shouldHydrateBoardWorkflowsCache]); + useEffect(() => { const runFetch = () => { const seq = ++boardWorkflowsFetchSeqRef.current; fetchBoardWorkflows(projectId) .then((payload) => { - if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload); + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload }); + writeBoardWorkflowsCache(projectId, payload); + } }) .catch(() => { if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); + setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); } }); }; @@ -509,6 +546,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // messages. We do NOT eagerly call `/api/github/batch-status` on board load. + const shouldGateLegacyBoard = boardWorkflows === null + ? (workflowColumnsEnabled === true || settingsLoaded === false) + : boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0; + + if (shouldGateLegacyBoard) { + return ; + } + if (workflowMode && selectedWorkflow) { return (
diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index 62abc29aea..1029795b02 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -1139,3 +1139,56 @@ FN-6529 requires list-view agent-active tasks to use a simple static highlight i min-height: 36px; } } + +/* +FNXC:BoardWorkflows 2026-06-20-09:12: +ListView needs a neutral workflow-loading shell under the same flag-aware gate as Board, avoiding a first-paint fallback to legacy list columns while workflow metadata revalidates. +*/ +.list-view--workflow-skeleton { + gap: var(--space-lg); +} + +.list-workflow-skeleton { + display: flex; + flex-direction: column; + gap: var(--space-md); + padding: var(--space-lg); + border: 1px solid var(--border); + background: var(--surface); +} + +.list-workflow-skeleton__row { + block-size: var(--space-xl); + inline-size: 100%; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--border) 70%, var(--surface)); + animation: list-workflow-skeleton-pulse 1.4s ease-in-out infinite; +} + +.list-workflow-skeleton__row--header { + inline-size: 60%; +} + +.list-workflow-skeleton__row--short { + inline-size: 78%; +} + +@keyframes list-workflow-skeleton-pulse { + 0%, 100% { + opacity: 0.55; + } + + 50% { + opacity: 1; + } +} + +@media (max-width: 768px) { + .list-view--workflow-skeleton { + gap: var(--space-md); + } + + .list-workflow-skeleton { + padding: var(--space-md); + } +} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index baa4dc49e6..7c796987f0 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -23,6 +23,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ". import { subscribeSse } from "../sse-bus"; import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; +import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; const COLUMN_COLOR_MAP: Record = { triage: "var(--triage)", @@ -241,6 +242,8 @@ interface ListViewProps { prAuthAvailable?: boolean; autoMerge?: boolean; onCreateWorkflow?: () => void; + workflowColumnsEnabled?: boolean; + settingsLoaded?: boolean; } const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({ @@ -305,6 +308,8 @@ export function ListView({ prAuthAvailable, autoMerge, onCreateWorkflow, + workflowColumnsEnabled, + settingsLoaded, }: ListViewProps) { const { t } = useTranslation("app"); const columnLabel = useColumnLabel(); @@ -313,7 +318,16 @@ export function ListView({ const [draggingTaskId, setDraggingTaskId] = useState(null); const [dragOverColumn, setDragOverColumn] = useState(null); const [selectedColumn, setSelectedColumn] = useState(null); - const [boardWorkflows, setBoardWorkflows] = useState(null); + /* + FNXC:BoardWorkflows 2026-06-20-09:07: + ListView shares the board-workflows first-paint invariant with Board: hydrate per-project workflow metadata from sessionStorage and gate legacy list columns while workflowColumns settings or uncached lane metadata are still unknown. + */ + const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false; + const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => { + const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; + return cached ? { projectId, payload: cached } : null; + }); + const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null; const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; @@ -393,16 +407,24 @@ export function ListView({ setSidebarWidth(readSidebarWidth(projectId)); }, [projectId, tasks]); + useEffect(() => { + const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; + setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); + }, [projectId, shouldHydrateBoardWorkflowsCache]); + useEffect(() => { const runFetch = () => { const seq = ++boardWorkflowsFetchSeqRef.current; fetchBoardWorkflows(projectId) .then((payload) => { - if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload); + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload }); + writeBoardWorkflowsCache(projectId, payload); + } }) .catch(() => { if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); + setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); } }); }; @@ -1689,6 +1711,22 @@ export function ListView({
); + const renderListWorkflowSkeleton = (empty = false) => ( +
+
+
+

{t("listView.title", "List View")}

+

{empty ? t("listView.noWorkflowLanes", "No workflow lanes available") : t("listView.loadingWorkflowLanes", "Loading workflow lanes")}

+
+
+