fix: descriptive waiting badges (Queued to revise / Queued behind FN-X) + dependency-free blocked exits replan calmly
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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: <reason>"`) 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: <reason>"`) 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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string, unknown>]) => "status" in p)?.[1] as Record<string, unknown>;
|
||||
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();
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user