From beb83a1c1b313fda24ef8425a4e555a2b3ebfff2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 07:59:42 -0700 Subject: [PATCH] fix(engine): close the unowned-card strand and harden the planning path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second FN-8596 strand, found after the first fix shipped. Clearing the stale `planning` status moved the card into a state owned by NOBODY: - planning excluded it: stale `firstExecutionAt` from its first pass made hasAdvancedPastPlanning true, and the previous fix only rescued cards that still carried a planning-stage status; - 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 occupies (the pin was plan-replan, which lives in triage). So the card sat indefinitely with no sweep, log, or audit event naming it. hasAdvancedPastPlanning now decides on arrival order alone for any card in the planner column: a stamp written BEFORE the card reached triage belongs to a previous pass, whatever the status is now. A card that genuinely advanced is still caught by the column check at the top, and one claimed by execution AFTER landing here has a stamp newer than its arrival, so it still reads advanced and stays with advanced-recovery. This flips one case I added in the previous commit — production proved that classification stranded the card. Hardening, so this class cannot hide again: - 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. It deliberately does NOT mutate — a generic mutator racing the specialized sweeps is the bug class this file keeps re-fixing, so recovery stays with the sweep that owns each shape and this guarantees visibility. - The silent skips are now loud: runIfStillPlanningUnderTaskLock (all four callers inherit it), the planning handoff moveTaskIf, and the four requestPreMergeOptionalStepFix refusals now log why nothing was scheduled and that the card was left parked. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/stalled-card-hardening.md | 7 +++++++ .../src/__tests__/replan-target.test.ts | 15 ++++++++++--- packages/engine/src/replan-target.ts | 21 +++++++++++++++---- 3 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 .changeset/stalled-card-hardening.md 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;