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:
7
.changeset/stalled-card-hardening.md
Normal file
7
.changeset/stalled-card-hardening.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user