diff --git a/.changeset/stalled-card-hardening.md b/.changeset/stalled-card-hardening.md new file mode 100644 index 0000000000..2eec3e2293 --- /dev/null +++ b/.changeset/stalled-card-hardening.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Cards can no longer sit waiting unowned, and every silent skip on the planning path now says so. +category: fix +dev: Closes the second FN-8596 strand: a triage card with stale execution stamps and NO status was owned by nobody — planning excluded it (stamps read as advanced) and `recoverAdvancedTriageTasks` also excluded it, because it bails on `workflowIrPinColumnId === "triage"`. `hasAdvancedPastPlanning` now decides purely on arrival order (a stamp predating `columnMovedAt` belongs to a previous pass) for any card in the planner column, whatever its status. Adds `SelfHealingManager.detectStalledCards`, a detect-only watchdog emitting `task:stall-watchdog-detected` for any non-terminal, unpaused card idle past 30m with no live session and no queued continuation — deduped per shape, never mutating (recovery stays with the sweep that owns each shape). Makes the previously silent skips observable: `runIfStillPlanningUnderTaskLock`, the planning handoff `moveTaskIf`, and the four `requestPreMergeOptionalStepFix` refusals now log why nothing was scheduled. diff --git a/packages/engine/src/__tests__/replan-target.test.ts b/packages/engine/src/__tests__/replan-target.test.ts index be4a510293..ec3321bb15 100644 --- a/packages/engine/src/__tests__/replan-target.test.ts +++ b/packages/engine/src/__tests__/replan-target.test.ts @@ -192,15 +192,24 @@ const planningGuardCases: PlanningGuardCase[] = [ }, stillPlanning: false, }, + /* + FNXC:WorkflowReplan 2026-07-26-20:30 (FN-8596, second strand): + A stale stamp with NO status is STILL plannable — this deliberately differs from the + no-`columnMovedAt` PR #2360 case above, and production forced the distinction. After the stale- + status sweep cleared `planning` to null, this exact shape was owned by NOBODY: planning excluded + it (stamps read as advanced) and `recoverAdvancedTriageTasks` also excluded it, because it bails + on `workflowIrPinColumnId === "triage"` — it cannot resume a card into the column it already + occupies. The card sat indefinitely. Arrival order is the honest signal regardless of status. + */ { - label: "stranded-advanced triage card with stale stamps but NO planning status (PR #2360)", + label: "triage card with stale stamps and NO status is still plannable (nobody else owns it)", task: { column: "triage", steps: [planStep("step-1")], executionStartedAt: "2026-07-26T13:50:57.686Z", - columnMovedAt: "2026-07-26T13:51:33.266Z", + columnMovedAt: "2026-07-26T14:17:59.396Z", }, - stillPlanning: false, + stillPlanning: true, }, { label: "triage card parked by a reviewer outage after an execution attempt", diff --git a/packages/engine/src/replan-target.ts b/packages/engine/src/replan-target.ts index 6f8ffaafee..f2cb0447c1 100644 --- a/packages/engine/src/replan-target.ts +++ b/packages/engine/src/replan-target.ts @@ -136,10 +136,23 @@ export function hasAdvancedPastPlanning( const stampPredatesArrival = Number.isFinite(arrivedAtMs) && Number.isFinite(newestStampMs) && newestStampMs < arrivedAtMs; - const claimedInPlannerLane = task.column === "triage" - && task.status != null - && PLANNING_STAGE_STATUSES.has(task.status); - if (!(stampPredatesArrival && claimedInPlannerLane)) { + /* + FNXC:WorkflowReplan 2026-07-26-20:30 (FN-8596, second strand): + The planner-lane test is the COLUMN, deliberately not the status. Requiring a planning-stage + status left a hole that stranded the same card a second time: after the stale-status sweep + cleared `planning` to null, the card had stale stamps and NO status, so planning excluded it + (stamps read as advanced) AND `recoverAdvancedTriageTasks` — the designated owner of that + "stranded-advanced" class — also excluded it, because it bails on + `workflowIrPinColumnId === "triage"` (it cannot resume a card into the column it already sits + in). Nobody owned the card and it sat indefinitely. + Arrival order alone is the honest signal: a stamp written BEFORE the card reached the planner + column belongs to a previous pass, whatever the status is now. A card that genuinely advanced + out of triage is caught by the column check at the top, and one that was claimed by execution + AFTER landing here has a stamp NEWER than its arrival, so it still reads advanced and stays with + the advanced-recovery sweep. + */ + const inPlannerLane = task.column === "triage"; + if (!(stampPredatesArrival && inPlannerLane)) { return true; } return false;