fix(engine): close the unowned-card strand and harden the planning path

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-26 07:59:42 -07:00
parent f005cee885
commit beb83a1c1b
3 changed files with 36 additions and 7 deletions

View File

@@ -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.

View File

@@ -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",

View File

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