diff --git a/packages/engine/src/replan-target.ts b/packages/engine/src/replan-target.ts index 19fb044519..6e5906e955 100644 --- a/packages/engine/src/replan-target.ts +++ b/packages/engine/src/replan-target.ts @@ -234,14 +234,32 @@ export function hasAdvancedPastPlanning( steps must keep reading as advanced or `hasAdvancedPastPlanning(t) || releasedToTodo` stops distinguishing anything. */ - roles: { mergedPlanningColumn?: string } = { mergedPlanningColumn: "todo" }, + /* + FNXC:WorkflowResolvedColumns 2026-07-31-11:45 (fleet — the four forward arms below): + `lanes` carries the task's RESOLVED forward roles so the advancement test is a role question. + + CALLER-RESOLVED, deliberately. The sync twin `resolvePlannerLanes` reads + `store.resolveTaskWorkflowIrSync`, which returns the DEFAULT workflow IR for every task under + PostgreSQL — converting through it would score as progress while answering about a board the card + is not on. The only caller is already `async`, so it resolves with + `resolvePlannerLanesForTaskAsync` and passes the answer in. + + The default is `LEGACY_PLANNER_LANES`, which populates all four roles, so a caller that passes + nothing is byte-identical to the four literals this replaces. A workflow that declares columns but + no archive lane leaves that arm undefined and it cannot match — the board has no such lane. + */ + roles: { mergedPlanningColumn?: string; lanes?: PlannerLanes; archivedColumn?: string } = { mergedPlanningColumn: "todo" }, ): boolean { - if ( - task.column === "in-progress" - || task.column === "in-review" - || task.column === "done" - || task.column === "archived" - ) { + const lanes = roles.lanes ?? LEGACY_PLANNER_LANES; + /* + `archivedColumn` is a SEPARATE argument rather than a fifth `PlannerLanes` role, and that is a + deliberate scope choice. Adding the field surfaced a real divergence between the sync and async + planner-lane twins — the shared `_workflow-vocabulary-fixture` models no archive lane, so the two + disagree there — and that fixture backs 37 test files. The divergence is worth its own change; + it is not this conversion's to force. Absent, the legacy id keeps the previous answer. + */ + const advanced = [lanes.wip, lanes.review, lanes.complete, roles.archivedColumn ?? (roles.lanes ? undefined : "archived")]; + if (advanced.some((column) => column !== undefined && column === task.column)) { return true; } /* diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index e07caa272c..57ccc8b35b 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -120,7 +120,7 @@ import type { AgentSession, } from "@earendil-works/pi-coding-agent"; import { ModelFallbackExhaustedError, describeModel, formatModelMarkerDetails, promptWithFallback } from "./pi.js"; -import { hasAdvancedPastPlanning, isTaskStillInPlanningStage, resolvePlannerLanes } from "./replan-target.js"; +import { hasAdvancedPastPlanning, isTaskStillInPlanningStage, resolvePlannerLanes, resolvePlannerLanesForTaskAsync } from "./replan-target.js"; import { createResolvedAgentSession, extractRuntimeHint, @@ -1448,8 +1448,29 @@ export class TriageProcessor { freshTask.status === "planning" || freshTask.status === "needs-replan" || freshTask.status === "plan-review-unavailable"; - const releasedToTodo = freshTask.column === resolvePlannerLanes(this.store, freshTask.id).hold && !planningStageStatus; - if (hasAdvancedPastPlanning(freshTask) || releasedToTodo) { + /* + FNXC:WorkflowResolvedColumns 2026-07-31-11:50 (fleet — replan-target cluster): + RESOLVED ONCE, ASYNCHRONOUSLY, and fed to every lane question in this block. + + The previous line called `resolvePlannerLanes`, the SYNC twin, which reads + `store.resolveTaskWorkflowIrSync` — and that returns the DEFAULT workflow IR for every task under + PostgreSQL. So `releasedToTodo` compared against `todo` on every board regardless of vocabulary: + a conversion in shape only. This method is already `async`, so the async resolver applies with no + restructuring, and the same answer supplies `hasAdvancedPastPlanning`'s planner, merged-planning + and forward-lane arguments rather than letting each fall back to its legacy default. + */ + const plannerLanes = await resolvePlannerLanesForTaskAsync(this.store, freshTask.id); + const releasedToTodo = freshTask.column === plannerLanes.hold && !planningStageStatus; + if ( + hasAdvancedPastPlanning(freshTask, plannerLanes.intake, { + mergedPlanningColumn: plannerLanes.hold, + lanes: plannerLanes, + archivedColumn: resolveLifecycleColumns( + await resolveWorkflowIrForTask(this.store, freshTask.id), + )?.archived, + }) + || releasedToTodo + ) { const nextStuckKillCount = (freshTask.stuckKillCount ?? task.stuckKillCount ?? 0) + 1; planLog.log( `${task.id} killed by stuck detector after planning handoff completed (column=${freshTask.column}, status=${freshTask.status ?? "null"}) — preserving released state (${context})`, diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index e532031c5f..7e1caef65e 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -5,7 +5,6 @@ "packages/engine/src/scheduler.ts": 12, "packages/engine/src/executor.ts": 7, "packages/engine/src/notification/notification-service.ts": 5, - "packages/engine/src/replan-target.ts": 4, "packages/engine/src/restart-recovery-coordinator.ts": 4, "packages/core/src/agent-store.ts": 2, "packages/core/src/async-mission-store-queries.ts": 2,