diff --git a/.changeset/descriptive-queue-badges-and-calm-blocked-park.md b/.changeset/descriptive-queue-badges-and-calm-blocked-park.md new file mode 100644 index 0000000000..2d6c66a2b6 --- /dev/null +++ b/.changeset/descriptive-queue-badges-and-calm-blocked-park.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Waiting states name their wait — Queued to revise, Queued behind FN-XXXX — and a dependency-free blocked exit replans without a failed badge. +category: fix +dev: getTaskStatusBadgeLabel gains a context param (idle, overlapBlockedBy) threaded from TaskCard/ListView; fn_task_done(outcome=blocked) with empty blockedBy parks needs-replan in the replan column (run-audit metadata gains parkedAs), dependency-carrying blocks unchanged. diff --git a/AGENTS.md b/AGENTS.md index 8569be7ae7..de7835bf36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -321,7 +321,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-7996: executor emits `task:execution-tool-failure-retry` for a claimed same-model consecutive-tool-failure retry and `task:execution-tool-failure-retry-exhausted` when the matching run budget is spent. Metadata is ids/counts/outcomes-only; the exhausted event is emitted once through a project-scoped compare-and-set while terminal parking remains idempotent. - FN-7998: executor emits `task:execution-escalation-retry` when its opt-in, single alternate model/node attempt is persisted after FN-7996 exhaustion, and `task:execution-escalation-exhausted` when that attempt also reaches the terminal park. Metadata remains ids/counts/outcomes-only (`taskId`, graph node id, target booleans, and prior retry count); no model identifiers or prose are persisted in run-audit. - FN-8004: `agent:heartbeat-move-skipped-soft-delete` records a heartbeat move that races a soft-deleted task without parking the durable agent. Metadata remains ids/timestamps/source only (`agentId`, optional `taskId`/`deletedAt`, `moveAttemptedAt`, optional `source`); it never stores error prose. -- FN-8141: the executor's `fn_task_done(outcome="blocked", reason=..., blockedBy?=[...])` honest-blocked exit emits `task:execution-blocked-parked` when an executor parks a genuinely-impossible task `failed` (`error = "BLOCKED: "`) instead of laundering it to `done` by skipping steps. It bypasses the completion/verdict/bulk-completion gates (blocked is not a completion claim), leaves steps in their true statuses, preserves worktree/branch, records `blockedBy` as real `task.dependencies` edges so the task requeues behind the blocker, and does NOT hand off to review — the parked row is honored by the executor's `status === "failed"` post-loop branch and is not auto-recovered into in-review by `recoverStrandedCompletedTodoTasks` (steps are not all done/skipped and `task.error` is set). Metadata stays ids/outcomes-only (`taskId`, `blockedBy` ids, `hasReason` boolean — never the reason prose). +- FN-8141: the executor's `fn_task_done(outcome="blocked", reason=..., blockedBy?=[...])` honest-blocked exit emits `task:execution-blocked-parked` when an executor parks a genuinely-impossible task `failed` (`error = "BLOCKED: "`) instead of laundering it to `done` by skipping steps. It bypasses the completion/verdict/bulk-completion gates (blocked is not a completion claim), leaves steps in their true statuses, preserves worktree/branch, records `blockedBy` as real `task.dependencies` edges so the task requeues behind the blocker, and does NOT hand off to review — the parked row is honored by the executor's `status === "failed"` post-loop branch and is not auto-recovered into in-review by `recoverStrandedCompletedTodoTasks` (steps are not all done/skipped and `task.error` is set). Metadata stays ids/outcomes-only (`taskId`, `blockedBy` ids, `hasReason` boolean, `parkedAs: "failed" | "auto-replan"` — never the reason prose). A blocked exit with EMPTY `blockedBy` parks as `needs-replan` in the replan column instead of `failed` (nothing external to wait for → the recovery is a replan; the failed badge alarmed operators while the overseer replanned anyway); dependency-carrying blocks keep the failed park. - FN-8305: durable symbol-lock operations emit `symbol-lock:acquired`, `symbol-lock:acquire-conflict`, `symbol-lock:renewed`, `symbol-lock:released`, `symbol-lock:reconcile-stale`, and deduplicated `symbol-lock:reconcile-stale-no-action`. Metadata is ids/counts/outcomes-only; normalized opaque symbol keys are permitted IDs, while raw symbol prose is not. - FN-8600: triage emits `task:plan-admission-throttled` when planning admission is withheld while eligible cards are waiting, recording the binding gate (`blockedBy`, now always `"running-agent cap"` — the cross-project semaphore that was the other value is deleted, and the four `semaphore*` fields went with it) plus `maxConcurrent`, `claimed`, `projectRoom`, `eligibleCount`, up to five `eligibleTaskIds`, `processingCount`, and up to five `processingTaskIds`. Metadata is ids/counts-only. Deduped on the gate signature INCLUDING the eligible task IDs, so a sustained stall collapses to one row while a new card's stall is never swallowed; the marker is set only after the write lands, so a failed write retries on the next poll. Purpose: before this event the binding gate existed only in a `planLog` line that is persisted nowhere, so "why did this card sit queued to plan?" was unanswerable after the fact. Reachable today by direct DB query only — the sole run-audit read route resolves through a durable agent's heartbeat run and this event uses a synthetic run id under `agentId:"triage"`. - FN-8592: startup and periodic self-healing emit `task:reconcile-stranded-hold-continuation` when an idle hold-column card with a real spec is re-seeded at its pre-release Plan Review, and deduped `task:reconcile-stranded-hold-continuation-no-action` for a candidate guard or race loss. Metadata stays ids/counts/outcomes-only (`taskId`, `column`, node/workflow identifiers, staleness or reason); healthy non-candidates are silent. The repair is insert-only and uses the shared per-task advisory transaction lock; global/engine pause and `autoMerge:false` defer to the operator. diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 6f0e822100..631206302f 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -25,7 +25,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection"; import { getRunningOptionalGateBadge, getRunningWorkflowStepLabel, getUnifiedTaskProgress } from "../utils/taskProgress"; import { isTaskAgentActive } from "../utils/taskActivity"; -import { getTaskStatusBadgeLabel, hasTaskStatusBadge } from "../utils/taskStatusBadgeLabel"; +import { getTaskStatusBadgeLabel, hasTaskStatusBadge , type TaskStatusBadgeContext} from "../utils/taskStatusBadgeLabel"; import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval"; import { useConfirm } from "../hooks/useConfirm"; import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete"; @@ -71,9 +71,9 @@ type SortField = "title" | "status" | "column" | "retries"; FNXC:MergeQueue 2026-07-15-10:45: List status column used to print raw engine statuses (landing/reviewing). Share the board badge mapper so list and card never diverge. */ -function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string): string { +function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string, context?: TaskStatusBadgeContext): string { if (status === "awaiting-approval") return t("tasks.awaitingApproval", "Awaiting Approval"); - return getTaskStatusBadgeLabel(status, t, workflowStepLabel); + return getTaskStatusBadgeLabel(status, t, workflowStepLabel, context); } type SortDirection = "asc" | "desc"; @@ -3082,7 +3082,7 @@ export function ListView({ ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") : isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") - : getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task)); + : getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null }); const hasDependencies = Boolean(task.dependencies && task.dependencies.length > 0); const taskProgress = getTaskProgress(task, getTaskColumnFlags(task)); const hasProgress = taskProgress.hasProgress; @@ -3349,7 +3349,7 @@ export function ListView({ ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") : isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") - : getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task)); + : getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null }); const isDragging = draggingTaskId === task.id; return ( diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index a336883801..d328110ae3 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -51,7 +51,7 @@ import { getRunningOptionalGateBadge, getRunningWorkflowStepLabel, getUnifiedTas import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity"; import { getPrBadgeModifierClass } from "../utils/prBadgeClass"; import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; -import { getTaskStatusBadgeLabel, hasTaskStatusBadge } from "../utils/taskStatusBadgeLabel"; +import { getTaskStatusBadgeLabel, type TaskStatusBadgeContext, hasTaskStatusBadge } from "../utils/taskStatusBadgeLabel"; import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval"; import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback"; import type { ToastType } from "../hooks/useToast"; @@ -345,8 +345,8 @@ status mapper retains its ellipsis-bearing output for ListView and other non-car Only strip a terminal Unicode ellipsis after the shared mapper resolves one of the active merge statuses so non-merge labels, status routing, and localization remain unchanged. */ -function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string): string { - const label = getTaskStatusBadgeLabel(status, t, workflowStepLabel); +function getTaskStatusLabel(status: string, t: TFunction<"app">, workflowStepLabel?: string, context?: TaskStatusBadgeContext): string { + const label = getTaskStatusBadgeLabel(status, t, workflowStepLabel, context); return ACTIVE_MERGE_STATUSES.has(status) && label.endsWith("…") ? label.slice(0, -1) : label; } @@ -3392,7 +3392,7 @@ function TaskCardComponent({ ? t("tasks.needsInput", "Needs input") : isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") - : getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task)); + : getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null }); const hasCardMetaBadges = showPriorityBadge || task.executionMode === "fast" // FNXC:PlannerOversight 2026-07-04-00:00: the oversight badge is opt-in diff --git a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts index 7cf3c1611b..27135a0418 100644 --- a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts +++ b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts @@ -15,6 +15,22 @@ export function hasTaskStatusBadge(status: string | null | undefined): boolean { return typeof status === "string" && status.trim().length > 0; } +/* +FNXC:TaskStatusBadge 2026-08-01-01:30 (operator: "make the badges more descriptive"): +Three waiting states read as ACTIVITY on the board and made healthy queueing look broken: +"Revising" while merely queued for a replan slot, and bare "queued" while parked behind another +task's file-scope lease (the reason lived only in a log line). The badge now names the wait: +"Queued to revise" (idle needs-replan) and "Queued behind FN-XXXX" (overlap lease). Liveness and +the overlap id are caller-supplied so this stays a pure mapper; omitted → legacy labels, so every +existing caller keeps its behavior. +*/ +export interface TaskStatusBadgeContext { + /** True when no agent session is live for the card (queued, not running). */ + idle?: boolean; + /** The task currently holding the overlapping file-scope lease, when queued behind one. */ + overlapBlockedBy?: string | null; +} + export function getTaskStatusBadgeLabel( status: string | null | undefined, t: TFunction<"app">, @@ -26,6 +42,7 @@ export function getTaskStatusBadgeLabel( mapping below is unchanged, so every existing caller keeps its behavior. */ workflowStepLabel?: string, + context?: TaskStatusBadgeContext, ): string { /* FNXC:TaskStatusBadge 2026-07-19-09:40: @@ -48,7 +65,13 @@ export function getTaskStatusBadgeLabel( engine token "needs-replan" unchanged and map centrally so board cards and list rows agree. */ if (status === "needs-replan") { - return t("tasks.statusReplan", "Revising"); + // Idle = waiting for a planning slot; the live revise cycle shows as "Revising"/step label. + return context?.idle + ? t("tasks.statusReplanQueued", "Queued to revise") + : t("tasks.statusReplan", "Revising"); + } + if (status === "queued" && context?.overlapBlockedBy) { + return t("tasks.statusQueuedBehind", "Queued behind {{taskId}}", { taskId: context.overlapBlockedBy }); } /* FNXC:TaskStatusBadge 2026-07-26-14:05: diff --git a/packages/engine/src/__tests__/executor-task-done-blocked.test.ts b/packages/engine/src/__tests__/executor-task-done-blocked.test.ts index 8c1f9f924c..3f8ae09d01 100644 --- a/packages/engine/src/__tests__/executor-task-done-blocked.test.ts +++ b/packages/engine/src/__tests__/executor-task-done-blocked.test.ts @@ -145,13 +145,37 @@ describe("FN-8141 fn_task_done honest blocked exit", () => { expect.objectContaining({ mutationType: "task:execution-blocked-parked", target: "FN-8141", - metadata: { taskId: "FN-8141", blockedBy: ["FN-8145"], hasReason: true }, + // FNXC:HonestBlockedExit 2026-08-01-01:45: `parkedAs` discriminates the dependency-free + // auto-replan park from the failed park (both ids/outcomes-only). + metadata: { taskId: "FN-8141", blockedBy: ["FN-8145"], hasReason: true, parkedAs: "failed" }, }), ); const auditCall = store.recordRunAuditEvent.mock.calls[0][0]; expect(JSON.stringify(auditCall.metadata)).not.toContain("secret blocker prose"); }); + it("parks a dependency-free block as needs-replan (auto-replan), never as an alarming failed badge", async () => { + /* + FNXC:HonestBlockedExit 2026-08-01-01:45 (operator report — FN-8634): + No blockedBy = nothing external to wait for; the recovery is a replan, which the overseer was + already performing AFTER the failed badge alarmed the operator. Reverting the auto-replan park + fails this test (status returns to "failed" with a BLOCKED error). + */ + const { store, tool } = await setup(); + + await tool.execute("id", { outcome: "blocked", reason: "requirements contradict each other" }); + + const patch = store.updateTask.mock.calls.find(([, p]: [string, Record]) => "status" in p)?.[1] as Record; + expect(patch.status).toBe("needs-replan"); + expect(patch.error).toBeNull(); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + mutationType: "task:execution-blocked-parked", + metadata: expect.objectContaining({ parkedAs: "auto-replan", blockedBy: [] }), + }), + ); + }); + it("leaves steps in their true statuses (no auto-done, no auto-skip)", async () => { const { store, tool } = await setup(); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index b75b2b7d7d..f7f03b060b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -17105,20 +17105,54 @@ export class TaskExecutor { const mergedDependencies = blockedByIds.length > 0 ? Array.from(new Set([...(blockedTask.dependencies ?? []), ...blockedByIds])) : undefined; - await store.updateTask(taskId, { - status: "failed", - error: parkError, - paused: false, - pausedByAgentId: null, - ...(mergedDependencies ? { dependencies: mergedDependencies } : {}), - }, this.getRunContextFor(taskId)); + /* + FNXC:HonestBlockedExit 2026-08-01-01:40 (operator: FN-8634 "shouldn't show a failed badge"): + When `blockedBy` is EMPTY there is nothing external to wait for — the block is a defect in + the PLAN (contradictory requirements, impossible step), and the designed recovery is a + replan, which the planner overseer was already performing after the alarm: park failed → + overseer "stage=executor signal=failed" → steer to replan. Skip the alarm: park directly + as `needs-replan` (the graph's durable replan signal) with the BLOCKED reason in the log, + and move to the replan column so triage re-admits it. A blocked exit WITH dependencies + keeps the failed park — waiting on other tasks is a real park an operator may act on. + The run-audit event fires for both shapes (`parkedAs` discriminates, ids/outcomes-only). + */ + const autoReplanPark = blockedByIds.length === 0; + if (autoReplanPark) { + const replanColumn = await resolveReplanTargetColumn(this.store, taskId); + await store.logEntry( + taskId, + `${parkError} — no blocking dependencies recorded; parking for automatic replan in ${replanColumn} (steps preserved)`, + undefined, + this.getRunContextFor(taskId), + ); + this.workflowLifecycleMovesInFlight.add(taskId); + try { + await moveTaskToReplanColumn(this.store, { id: taskId, column: blockedTask.column }, replanColumn); + } finally { + this.workflowLifecycleMovesInFlight.delete(taskId); + } + await store.updateTask(taskId, { + status: "needs-replan", + error: null, + paused: false, + pausedByAgentId: null, + }, this.getRunContextFor(taskId)); + } else { + await store.updateTask(taskId, { + status: "failed", + error: parkError, + paused: false, + pausedByAgentId: null, + ...(mergedDependencies ? { dependencies: mergedDependencies } : {}), + }, this.getRunContextFor(taskId)); - await store.logEntry( - taskId, - `${parkError}${blockedByIds.length > 0 ? ` — recorded dependencies: ${blockedByIds.join(", ")}` : ""} — parked failed (honest blocked exit; steps preserved)`, - undefined, - this.getRunContextFor(taskId), - ); + await store.logEntry( + taskId, + `${parkError} — recorded dependencies: ${blockedByIds.join(", ")} — parked failed (honest blocked exit; steps preserved)`, + undefined, + this.getRunContextFor(taskId), + ); + } await this.store.recordRunAuditEvent?.({ taskId, agentId: "executor", @@ -17130,15 +17164,18 @@ export class TaskExecutor { taskId, blockedBy: blockedByIds, hasReason: true, + parkedAs: autoReplanPark ? "auto-replan" : "failed", }, }); await this.persistTokenUsage(taskId); - executorLog.log(`⛔ ${taskId} parked failed via blocked exit${blockedByIds.length > 0 ? ` (blockedBy: ${blockedByIds.join(", ")})` : ""}`); + executorLog.log(`⛔ ${taskId} ${autoReplanPark ? "parked for automatic replan via blocked exit (no dependencies)" : `parked failed via blocked exit (blockedBy: ${blockedByIds.join(", ")})`}`); return { content: [{ type: "text" as const, - text: `Task parked as blocked (failed). ${blockedByIds.length > 0 ? `Recorded ${blockedByIds.length} blocking dependency(ies); it will requeue once they complete. ` : ""}Steps left in their true statuses; no completion recorded.`, + text: autoReplanPark + ? "Task parked as blocked with no blocking dependencies — queued for automatic replan so the plan can resolve the conflict. Steps left in their true statuses; no completion recorded." + : `Task parked as blocked (failed). Recorded ${blockedByIds.length} blocking dependency(ies); it will requeue once they complete. Steps left in their true statuses; no completion recorded.`, }], details: {}, };