diff --git a/.changeset/self-healing-cluster-round2.md b/.changeset/self-healing-cluster-round2.md new file mode 100644 index 0000000000..67675476b9 --- /dev/null +++ b/.changeset/self-healing-cluster-round2.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Three more self-healing repairs use your board's own column names, including one that could disturb a running task. +category: fix +dev: Converts the worktree-metadata reconcile, orphaned-pending-step-results, and agent-link-drift sweeps in `self-healing.ts` to `resolveProjectColumnsForRoles`. diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index cf43f1f8ca..f65fa1cd94 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -5359,9 +5359,19 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } let repaired = 0; + /* + FNXC:WorkflowResolvedColumns 2026-07-31-16:40 (fleet, self-healing round 2): + TERMINAL/WIP/REVIEW roles for this sweep's three lane questions. Keyed on ids the terminal skip + never fired on a renamed board (finished cards were rebound every pass) and — the dangerous half — + the FN-5256 liveness guard below went silent, so the sweep could clear worktree metadata out from + under a running shell. + */ + const worktreeReconcileTerminalColumns = await resolveProjectColumnsForRoles(this.store, TERMINAL_ROLES); + const worktreeReconcileWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); + const worktreeReconcileReviewColumns = await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES); for (const task of allTasks) { if (!task.worktree) continue; - if (!options?.includeTaskIds?.has(task.id) && (task.column === "done" || task.column === "archived")) { + if (!options?.includeTaskIds?.has(task.id) && worktreeReconcileTerminalColumns.has(task.column)) { continue; } @@ -5398,8 +5408,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const scopeOverrideMergeActiveSafe = task.scopeOverride === true - && task.column !== "in-progress" - && (task.column !== "in-review" || (typeof task.status === "string" && RECONCILE_SCOPE_OVERRIDE_MERGE_ACTIVE_STATUS_SET.has(task.status))); + /* Same resolved sets as the liveness guard below, so the two cannot disagree about live lanes. */ + && !worktreeReconcileWipColumns.has(task.column) + && (!worktreeReconcileReviewColumns.has(task.column) || (typeof task.status === "string" && RECONCILE_SCOPE_OVERRIDE_MERGE_ACTIVE_STATUS_SET.has(task.status))); if (scopeOverrideMergeActiveSafe) { /* FNXC:MissingWorktreeRecovery 2026-07-10-18:23: @@ -5427,7 +5438,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence { // live task's worktree looks stale here (and we couldn't rebind to a live // fusion/), the executor's own recovery paths will detect and recreate // it. Clearing here yanks the worktree from a still-running shell. - if (task.column === "in-progress" || task.column === "in-review") { + if (worktreeReconcileWipColumns.has(task.column) || worktreeReconcileReviewColumns.has(task.column)) { await this.emitWorktreeMetadataAuditEvent({ taskId: task.id, mutationType: "task:auto-recover-worktree-metadata-skipped-active", @@ -7397,6 +7408,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence { async reconcileOrphanedPendingStepResults(): Promise { try { + /* Resolved once per sweep, outside the paging loop, so a large board still pays one resolve. */ + const orphanedPendingWipColumns = await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]); const pageSize = 500; let offset = 0; let recovered = 0; @@ -7416,15 +7429,20 @@ export class SelfHealingManager extends SelfHealingGitEvidence { for (const task of tasks) { // An operator park is authoritative; this sweep must not reach through it. if (task.userPaused === true) continue; - // Executor-owned rows: resume is deferred at startup, liveness unprovable here. - if (task.column === "in-progress") continue; + /* + FNXC:WorkflowResolvedColumns 2026-07-31-16:45 (fleet, self-healing round 2): + The executor-owned skip is a WIP-ROLE question. Keyed on the id it stopped firing on a renamed + board, so this sweep would rewrite `pending` step results out from under a LIVE executor run — + the one thing its own header says it must never do. + */ + if (orphanedPendingWipColumns.has(task.column)) continue; if (!task.workflowStepResults?.some((result) => result.status === "pending")) continue; if (isSessionLive(task.id)) continue; // Re-read the live row before mutating: the page snapshot can be stale against // a merger/planner that wrote a fresh pending lease after the page was fetched. const fresh = await this.store.getTask(task.id); - if (!fresh || fresh.userPaused === true || fresh.column === "in-progress") continue; + if (!fresh || fresh.userPaused === true || orphanedPendingWipColumns.has(fresh.column)) continue; /* FNXC:WorkflowReviewGates 2026-07-26-15:50: Honor a LIVE review-gate lease, not just in-process session liveness. @@ -12988,6 +13006,11 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const now = Date.now(); const recoveredAgentIds = new Set(); const runningAgents = await agentStore.listAgents({ state: "running", includeEphemeral: true }); + const agentLinkTerminalColumns = await resolveProjectColumnsForRoles(this.store, TERMINAL_ROLES); + const agentLinkLiveColumns = new Set([ + ...await resolveProjectColumnsForRoles(this.store, ["countsTowardWip"]), + ...await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES), + ]); for (const agent of runningAgents) { if (isEphemeralAgent(agent) || !agent.taskId) { @@ -12995,7 +13018,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence { } const linkedTask = await this.store.getTask(agent.taskId); - if (linkedTask && (linkedTask.column === "in-progress" || linkedTask.column === "in-review" || linkedTask.column === "done" || linkedTask.column === "archived")) { + /* FNXC:WorkflowResolvedColumns 2026-07-31-16:50 (fleet, round 2): WIP u REVIEW u TERMINAL. Keyed on + ids none matched on a renamed board, so this sweep evaluated agents whose task was still executing. */ + if (linkedTask && (agentLinkLiveColumns.has(linkedTask.column) || agentLinkTerminalColumns.has(linkedTask.column))) { continue; } diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 615f83c650..bc4571ba48 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -1,7 +1,7 @@ { "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline", "byFile": { - "packages/engine/src/self-healing.ts": 38, + "packages/engine/src/self-healing.ts": 26, "packages/engine/src/notification/notification-service.ts": 5, "packages/engine/src/executor.ts": 4, "packages/core/src/task-store/moves.ts": 2,