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);