FN-7863: add bounded execute-node self-requeue loop guard

Bounds the execute->pause-abort->todo dispatch loop so a task can no longer requeue forever with no visible signal or terminal state.

- Track a progress-anchored `executeRequeueLoopCount`/`executeRequeueLoopSignature` pair on the task row (current step + step statuses) so slow no-progress requeue cycles are counted independently of the scheduler's wall-clock `dispatchStormCount` guard.
- Warn visibly in the task log at `EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD` (3) and terminalize non-paused, non-terminal tasks at `MAX_EXECUTE_REQUEUE_LOOP_CYCLES` (6) with `status:"failed"` and an `EXECUTION_DISPATCH_LOOP_EXHAUSTED:` error, preserving worktree/branch/step progress.
- Emit a new `task:execution-dispatch-loop-terminalized` run-audit mutation type with ids/counts/outcomes-only metadata.
- Reset the loop counters on real progress, manual retry, forward moves (in-review/done/archived), and unpause, in both the executor and scheduler.
- Add DB migration 142 (`executeRequeueLoopCount`, `executeRequeueLoopSignature` columns) plus store read/write/reset plumbing.
- Add reliability-interactions coverage for the new loop guard and extend store-persistence tests for the new columns.
- Document the new behavior in AGENTS.md and docs/architecture.md.

Files changed:
 AGENTS.md                                              |   1 +
 docs/architecture.md                                   |   2 +
 packages/core/src/__tests__/store-persistence.test.ts  |  45 +++++
 packages/core/src/db.ts                                |  17 +-
 packages/core/src/manual-retry-reset.ts                |   1 +
 packages/core/src/store.ts                             |  22 ++-
 packages/core/src/types.ts                             |  11 ++
 .../execute-requeue-loop-guard.test.ts                 | 188 +++++++++++++++
 packages/engine/src/executor.ts                        |  67 +++++++-
 packages/engine/src/run-audit.ts                       |   2 +
 packages/engine/src/scheduler.ts                       |   8 +-
 11 files changed, 355 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-7863
Fusion-Task-Lineage: db40507f-5851-435e-8854-c1ed695b4154
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 11:56:54 -07:00
parent 0c97c161ee
commit 9cfb40e137
11 changed files with 355 additions and 9 deletions

View File

@@ -226,6 +226,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- FN-7787: `createResolvedAgentSession` enriches `session:runtime-resolved` with `noModelResolved: true` and `runtimeBuiltInFallbackModel` when a non-mock/non-test session reaches runtime creation without a complete provider/model pair; this is a visibility signal for runtime built-in fallback usage, not a fabricated model-resolution verdict.
- FN-7835/FN-7844/FN-7859: durable-agent error-state recovery emits `agent:auto-recover-error-state` when either the heartbeat timer or the self-healing sweep clears a transient, non-operator-actionable `error` and retries; metadata stays ids/counts/outcomes-only (`agentId`, attempt, limit, source), where `source` is `timer`/`automation`/`self-healing`. Both entry paths share the `heartbeatErrorRecovery` budget (self-healing keeps `durableErrorRecovery` only for cooldown/stale-path bookkeeping) and emit `agent:error-retry-exhausted` when the shared budget is exhausted and the agent is parked `paused` with `pauseReason:"error-retry-exhausted"`. Non-recoverable durable heartbeat errors (permanent/usage-limit/operator-actionable, excluding stale worktree/module-resolution suppression and disabled/ephemeral/actively-executing agents) emit `agent:error-parked-unrecoverable` with ids/counts/outcomes-only metadata (`agentId`, `source`, optional `attempts`, `limit`) and park `paused` with `pauseReason:"error-unrecoverable"` for human repair.
- FN-7802: self-healing emits `task:reconcile-missing-worktree-merge-active` when it proves an `in-review` merge-active task (`merging`/`merging-pr`/`merging-fix`) is stranded by an unusable-worktree session-start failure, clears stale `worktree`/`branch`/`sessionFile`, resets the worktree-session retry budget, increments `recoveryRetryCount` as the bounded stale-metadata clear counter, and requeues to `todo`; it emits `task:reconcile-missing-worktree-merge-active-no-action` when `autoMerge:false`, workspace-task ownership, or triple-proof blocks the backward move.
- FN-7863: executor emits `task:execution-dispatch-loop-terminalized` when an execute-node self-requeue loop reaches `MAX_EXECUTE_REQUEUE_LOOP_CYCLES` with an unchanged progress signature; metadata stays ids/counts/outcomes-only (`taskId`, `cycleCount`, `maxCycles`, `progressSignature`, `failureValue`) and the task is visibly failed with `EXECUTION_DISPATCH_LOOP_EXHAUSTED:` while preserving worktree/branch/step progress.
- FN-7011: self-healing emits `task:reconcile-engine-downtime-active-timing` when startup recovery shifts active task segment anchors to exclude proven engine-process downtime, and `task:reconcile-engine-downtime-active-timing-no-action` when no active task qualifies.
- FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes.
- FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move.

View File

@@ -687,6 +687,7 @@ Runtime action-gate flow (v1):
- `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`.
- `recoverPausedAbortFailures()` clears executor pause/resume abort parks only when the durable row is safe to recover. `todo`/`in-progress` rows are requeued for normal scheduling, while clean `in-review` rows (completed steps, not paused/user-paused/executing, auto-merge eligible, no confirmed or terminal merge evidence) have `status`/`error` cleared in place so review progression can continue. FN-7749 adds the manual-hold exception to the prior `autoMerge:false` guard: a benign hard-cancel pause/resume abort at a merge-region/manual-hold node is the healthy Merge & Close resting state, so already-parked rows of that exact shape are cleared in place without moving backward (FN-5147-compliant). User hard-cancel, global/user pause, terminal merge, live-execution, and other `autoMerge:false` guards remain operator-actionable. Successful recovery emits `task:auto-recover-paused-abort-park` with `preservedInReview` metadata.
- Workflow graph pause/resume is node-reentrant for typed engine-internal interruptions. When `WorkflowGraphExecutor` sees the graph abort signal or a node returns `value: "aborted"`, it stamps the interrupted node and `engine-pause` abort kind into graph context. `TaskExecutor` then uses the existing bounded `graphResumeRetryCount` budget to clear the transient abort, suppress failure notification with an `Auto-recovered:` task log, and re-enter the graph/task only under the same safety guards: no user/active global pause, no merge/finalize provenance, no genuine node failure, no terminal merge value, no `autoMerge:false` protected review row, and no active execution owner. Global-pause provenance from the graph-controller abort is re-entrant once the global pause has been lifted because it represents the same in-flight node interruption. Generic legacy pause-abort parks without the typed node marker remain operator-action failures except for the narrow `in-review`/`plan` stale-replay shape: hard-cancel pause provenance, `node:plan:value === "aborted"`, no typed interrupted node, no active task/user/global pause, no terminal merge value, no confirmed merge, auto-merge eligibility, and only a clean row or the exact stale plan pause-abort failure. That path logs `stale replay ignored`, clears only the stale failure state when present, preserves `in-review`, and never re-enters planning or moves the task to `todo`.
- FN-7863 adds a progress-anchored guard at the execute-node self-requeue funnel (`failedNode === "execute"` and either the live row is `todo` or the in-process self-requeue marker proves a stale `in-progress` read). The guard persists `executeRequeueLoopCount` plus `executeRequeueLoopSignature` (`currentStep` + step statuses), warns at `EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD = 3`, and terminalizes at `MAX_EXECUTE_REQUEUE_LOOP_CYCLES = 6` with `status:"failed"` and an `EXECUTION_DISPATCH_LOOP_EXHAUSTED:` error. It preserves worktree/branch/step progress, skips paused/user-paused/done/archived terminalization, resets on signature progress, manual retry, successful completion, forward moves, and unpause, and complements rather than replaces the scheduler's wall-clock `dispatchStormCount` guard (fast flapping stays scheduler-owned; slow no-progress flapping is caught here).
- `reattach-orphaned-assigned-executions` is a forward-resume safety net for durable-agent assignments. During startup recovery and periodic maintenance, after orphaned-agent and stale-heartbeat-run repairs, self-healing finds `in-progress` tasks with an `assignedAgentId` whose agent has no active heartbeat run and no active executor session after the orphan grace window. It re-dispatches in place via `executor.resumeTaskForAgent(agentId)` (the same seam used by clean `HeartbeatMonitor.onRunCompleted` and guarded by executor double-execution checks), emits `task:reattach-orphaned-execution`, and never moves the task backward. This complements engine-start `executor.resumeOrphaned()` and leaves unassigned/role-based execution recovery to the existing startup/limbo/stuck-task paths.
- Durable `Agent.taskId` is a running assignment for parked `todo`/`triage` task rows only when the agent has live proof: a fresh active heartbeat run or an executor-active/tracked heartbeat signal. Scheduler overlap requeues, task move sync, self-healing, and Reports Health Check share this invariant: stale durable links are cleared or rendered as stale while `status: "queued"` and `overlapBlockedBy` remain on the task row so file-scope lease blocking is not weakened. `fn_list_agents` and `fn_agent_show` render the linked task column next to `Current Task` (for example `Current Task: FN-1234 (triage)` or `Current Task: FN-1234 (not active — done)`) so parked-column planning ownership is not misread as in-progress execution drift.
- Mission validation has a dedicated stale-run reaper: startup recovery and Batch 2 maintenance call `reapStaleMissionValidatorRuns()` when wired by the runtime, using `VALIDATOR_RUN_STALE_MAX_AGE_MS` (currently 6 hours). The sweep terminates ownerless `mission_validator_runs.status='running'` rows as `error`, writes the reap reason into `summary`, leaves `lastValidatorRunId` pointing at the now-terminal run, and emits run-audit telemetry with `mutationType: "mission:validator-run-reaped"` plus `runId`/`featureId`/`missionId`/`triggerType`/`elapsedMs` metadata. Active mission features move to `loopState="needs_fix"` + `lastValidatorStatus="error"` unless their parent mission is already `complete`/`archived`.
@@ -749,6 +750,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/
- FN-6782/FN-6796: `task:auto-recover-paused-abort-park` records self-healing recovery of pause-abort operator parks. Metadata includes the source column and whether recovery preserved a clean `in-review` row instead of requeueing to `todo`.
- FN-7069: `task:reconcile-phantom-committed-reservation` records task-store startup or self-healing cleanup of committed-reservation-without-task phantoms. Metadata includes `reservationStatus: "committed"` plus pruned `activityLog` and `agents` counts; `runAuditEvents` and the committed reservation are intentionally retained for auditability and ID permanence.
- FN-7074: `task:reservation-commit-rolled-back` records preventive create-path rollback when a distributed reservation was committed with the task-row insert but a later create materialization step failed. Metadata includes `{ reservationId, nodeId, reason: "failed-create", error }`; the task row/partial directory are removed and the reservation is moved to `aborted` so FN-7069 should not need to clean up a new phantom.
- FN-7863: `task:execution-dispatch-loop-terminalized` records executor terminalization of a no-progress execute-node self-requeue loop. Metadata includes `{ taskId, cycleCount, maxCycles, progressSignature, failureValue }`; the task row carries `EXECUTION_DISPATCH_LOOP_EXHAUSTED:` and remains in its visible failed state with committed/step progress preserved.
- FN-4956: Layer 3 merge-conflict arbitration now scope-partitions conflicted files before AI resolution. Out-of-scope conflicts are deterministically resolved to the integration branch (`git checkout --ours`) and unstaged, while only in-scope conflicts flow to AI. Integration branch defaults are resolved via `resolveIntegrationBranch(rootDir, settings)`. Audit events: `merge:layer3:foreign-file-skipped` and `merge:layer3:scope-override-bypass`.
- FN-5655 goal anchoring observability adds `database`-domain mutation types `goal:injection-applied`, `goal:injection-skipped`, and `goal:retrieval-invoked` so Slice 2 cite-rate tracking has a prompt-independent signal. Metadata uses counts/IDs only (`count`, `lane`, `toolName`, optional `truncated`/`reason`/`notFound`) and never stores prompt bodies or goal titles/descriptions. These events surface through `GET /api/agents/:id/runs/:runId/audit` and support the existing `startTime`/`endTime` filters.

View File

@@ -59,6 +59,51 @@ describe("TaskStore", () => {
});
});
/*
* FNXC:WorkflowLifecycle 2026-07-12-00:00:
* FN-7863's execute self-requeue loop guard is progress-anchored, so both the no-progress streak
* and the compact step-status signature must round-trip through SQLite and list surfaces.
*/
describe("executeRequeueLoop persistence", () => {
it("round-trips execute loop counters through updateTask, getTask, and listTasks", async () => {
const task = await harness.store().createTask({ description: "Execute requeue loop task" });
const updated = await harness.store().updateTask(task.id, {
executeRequeueLoopCount: 3,
executeRequeueLoopSignature: JSON.stringify({ currentStep: 1, steps: ["done", "pending"] }),
});
expect(updated.executeRequeueLoopCount).toBe(3);
expect(updated.executeRequeueLoopSignature).toBe('{"currentStep":1,"steps":["done","pending"]}');
const detail = await harness.store().getTask(task.id);
expect(detail.executeRequeueLoopCount).toBe(3);
expect(detail.executeRequeueLoopSignature).toBe('{"currentStep":1,"steps":["done","pending"]}');
const listed = (await harness.store().listTasks()).find((candidate) => candidate.id === task.id);
expect(listed?.executeRequeueLoopCount).toBe(3);
expect(listed?.executeRequeueLoopSignature).toBe('{"currentStep":1,"steps":["done","pending"]}');
});
it("clears execute loop state with explicit null updates", async () => {
const task = await harness.store().createTask({ description: "Execute requeue loop clear task" });
await harness.store().updateTask(task.id, {
executeRequeueLoopCount: 2,
executeRequeueLoopSignature: "sig",
});
const cleared = await harness.store().updateTask(task.id, {
executeRequeueLoopCount: null,
executeRequeueLoopSignature: null,
});
expect(cleared.executeRequeueLoopCount).toBeUndefined();
expect(cleared.executeRequeueLoopSignature).toBeUndefined();
const detail = await harness.store().getTask(task.id);
expect(detail.executeRequeueLoopCount).toBe(0);
expect(detail.executeRequeueLoopSignature).toBeUndefined();
});
});
/*
* FNXC:PlanApproval 2026-07-04-22:41:
* FN-7569 — approvedPlanFingerprint must survive create/update/null-clear round trips through

View File

@@ -184,7 +184,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 141;
const SCHEMA_VERSION = 142;
const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16;
@@ -285,9 +285,11 @@ CREATE TABLE IF NOT EXISTS tasks (
mergeRetries INTEGER,
workflowStepRetries INTEGER,
resumeLimboCount INTEGER DEFAULT 0,
executeRequeueLoopCount INTEGER DEFAULT 0,
graphResumeRetryCount INTEGER DEFAULT 0,
resumeLimboTipSha TEXT,
resumeLimboStepSignature TEXT,
executeRequeueLoopSignature TEXT,
recoveryRetryCount INTEGER,
taskDoneRetryCount INTEGER DEFAULT 0,
worktreeSessionRetryCount INTEGER DEFAULT 0,
@@ -5673,6 +5675,19 @@ export class Database {
});
}
if (version < 142) {
/*
* FNXC:WorkflowLifecycle 2026-07-12-00:00:
* FN-7863 stores the progress-anchored execute self-requeue streak so slow
* execute→pause-abort→todo loops survive scheduler cadence gaps and terminalize
* visibly instead of burning executor slots indefinitely.
*/
this.applyMigration(142, () => {
this.addColumnIfMissing("tasks", "executeRequeueLoopCount", "INTEGER DEFAULT 0");
this.addColumnIfMissing("tasks", "executeRequeueLoopSignature", "TEXT");
});
}
}
/**

View File

@@ -5,6 +5,7 @@ export const IN_REVIEW_STALL_DEADLOCK_PAUSE_REASON = "in-review-stall-deadlock";
export const MANUAL_RETRY_RESET_COUNTER_KEYS = [
"stuckKillCount",
"resumeLimboCount",
"executeRequeueLoopCount",
"graphResumeRetryCount",
"recoveryRetryCount",
"taskDoneRetryCount",

View File

@@ -251,9 +251,11 @@ interface TaskRow {
workflowStepRetries: number | null;
stuckKillCount: number | null;
resumeLimboCount: number | null;
executeRequeueLoopCount: number | null;
graphResumeRetryCount: number | null;
resumeLimboTipSha: string | null;
resumeLimboStepSignature: string | null;
executeRequeueLoopSignature: string | null;
postReviewFixCount: number | null;
recoveryRetryCount: number | null;
taskDoneRetryCount: number | null;
@@ -419,9 +421,11 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [
defineTaskColumn("workflowStepRetries", (task) => task.workflowStepRetries ?? null),
defineTaskColumn("stuckKillCount", (task) => task.stuckKillCount ?? 0),
defineTaskColumn("resumeLimboCount", (task) => task.resumeLimboCount ?? 0),
defineTaskColumn("executeRequeueLoopCount", (task) => task.executeRequeueLoopCount ?? 0),
defineTaskColumn("graphResumeRetryCount", (task) => task.graphResumeRetryCount === undefined ? 0 : task.graphResumeRetryCount),
defineTaskColumn("resumeLimboTipSha", (task) => task.resumeLimboTipSha ?? null),
defineTaskColumn("resumeLimboStepSignature", (task) => task.resumeLimboStepSignature ?? null),
defineTaskColumn("executeRequeueLoopSignature", (task) => task.executeRequeueLoopSignature ?? null),
defineTaskColumn("postReviewFixCount", (task) => task.postReviewFixCount ?? 0),
defineTaskColumn("recoveryRetryCount", (task) => task.recoveryRetryCount ?? null),
defineTaskColumn("taskDoneRetryCount", (task) => task.taskDoneRetryCount ?? 0),
@@ -2152,9 +2156,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
workflowStepRetries: row.workflowStepRetries ?? undefined,
stuckKillCount: row.stuckKillCount ?? undefined,
resumeLimboCount: row.resumeLimboCount ?? undefined,
executeRequeueLoopCount: row.executeRequeueLoopCount ?? undefined,
graphResumeRetryCount: row.graphResumeRetryCount ?? undefined,
resumeLimboTipSha: row.resumeLimboTipSha || undefined,
resumeLimboStepSignature: row.resumeLimboStepSignature || undefined,
executeRequeueLoopSignature: row.executeRequeueLoopSignature || undefined,
postReviewFixCount: row.postReviewFixCount ?? undefined,
recoveryRetryCount: row.recoveryRetryCount ?? undefined,
taskDoneRetryCount: row.taskDoneRetryCount ?? undefined,
@@ -2738,7 +2744,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "executionMode", "plannerOversightLevel", "awaitingApprovalReason", "approvedPlanFingerprint",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt",
@@ -2834,7 +2840,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "executionMode", "plannerOversightLevel", "awaitingApprovalReason", "approvedPlanFingerprint",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt",
@@ -8597,7 +8603,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; plannerOversightLevel?: import("./types.js").PlannerOversightLevel | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; approvedPlanFingerprint?: string | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; gitlabTracking?: (Omit<import("./types.js").TaskGitLabTracking, "item"> & { item?: import("./types.js").TaskGitLabTrackedItem | null }) | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; workflowTransitionNotification?: import("./types.js").Task["workflowTransitionNotification"] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; plannerOversightLevel?: import("./types.js").PlannerOversightLevel | null; awaitingApprovalReason?: import("./types.js").Task["awaitingApprovalReason"] | null; approvedPlanFingerprint?: string | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; gitlabTracking?: (Omit<import("./types.js").TaskGitLabTracking, "item"> & { item?: import("./types.js").TaskGitLabTrackedItem | null }) | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; workflowTransitionNotification?: import("./types.js").Task["workflowTransitionNotification"] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
/*
@@ -9343,6 +9349,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
} else if (updates.resumeLimboCount !== undefined) {
task.resumeLimboCount = updates.resumeLimboCount;
}
if (updates.executeRequeueLoopCount === null) {
task.executeRequeueLoopCount = undefined;
} else if (updates.executeRequeueLoopCount !== undefined) {
task.executeRequeueLoopCount = updates.executeRequeueLoopCount;
}
if (updates.graphResumeRetryCount === null) {
task.graphResumeRetryCount = null;
} else if (updates.graphResumeRetryCount !== undefined) {
@@ -9358,6 +9369,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
} else if (updates.resumeLimboStepSignature !== undefined) {
task.resumeLimboStepSignature = updates.resumeLimboStepSignature;
}
if (updates.executeRequeueLoopSignature === null) {
task.executeRequeueLoopSignature = undefined;
} else if (updates.executeRequeueLoopSignature !== undefined) {
task.executeRequeueLoopSignature = updates.executeRequeueLoopSignature;
}
if (updates.postReviewFixCount === null) {
task.postReviewFixCount = undefined;
} else if (updates.postReviewFixCount !== undefined) {

View File

@@ -2452,6 +2452,14 @@ export interface Task {
* Incremented by self-healing for resume-limbo detection and reset when
* progress is observed or recovery escalates to a fresh todo dispatch. */
resumeLimboCount?: number;
/**
* FNXC:WorkflowLifecycle 2026-07-12-00:00:
* FN-7863 bounds execute-node self-requeue loops by counting consecutive requeues
* that preserve the same execution-progress signature. Reset this counter on real
* progress, forward moves, and manual retry; the executor caps it before writing
* terminal status:"failed" so committed work and step progress remain visible.
*/
executeRequeueLoopCount?: number;
/** Bounded auto-retry attempts for transient workflow-graph failures observed
* immediately after engine-restart or unpause resume. Reset by manual retry
* and by successful forward progress; capped by the executor before terminal
@@ -2463,6 +2471,9 @@ export interface Task {
/** Compact execution-progress snapshot captured at the last reclaim/unpause
* attempt (current step + step statuses) for resume-limbo detection. */
resumeLimboStepSignature?: string;
/** Compact execution-progress snapshot captured at the last execute-node
* self-requeue (current step + step statuses) for FN-7863 loop detection. */
executeRequeueLoopSignature?: string;
/** Number of times workflow remediation has auto-revived this task after
* failed pre-merge review feedback. Incremented each time the engine sends the
* task back with failure feedback injected. Capped only when the workflow step

View File

@@ -0,0 +1,188 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail } from "@fusion/core";
import "../executor-test-helpers.js";
import {
EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD,
MAX_EXECUTE_REQUEUE_LOOP_CYCLES,
TaskExecutor,
} from "../../executor.js";
import { createMockStore, resetExecutorMocks } from "../executor-test-helpers.js";
const now = "2026-07-12T00:00:00.000Z";
function task(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-7863-T",
title: "Execute requeue loop",
description: "Bound execute self-requeue loops",
column: "todo",
dependencies: [],
steps: [{ name: "Implement", status: "pending" }],
currentStep: 0,
log: [],
branch: "fusion/fn-7863",
baseBranch: "main",
worktree: "/tmp/fusion-fn-7863",
status: null,
error: null,
paused: false,
userPaused: false,
autoMerge: true,
mergeRetries: 0,
createdAt: now,
updatedAt: now,
...overrides,
} as TaskDetail;
}
function harness(initial: TaskDetail) {
resetExecutorMocks();
const store = createMockStore();
let live = { ...initial } as TaskDetail;
store.getTask.mockImplementation(async () => live);
store.updateTask.mockImplementation(async (_id: string, updates: Partial<TaskDetail>) => {
live = { ...live, ...updates } as TaskDetail;
return live;
});
store.recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
const executor = new TaskExecutor(store, "/tmp/test");
return {
store,
executor,
get live() {
return live;
},
setLive(patch: Partial<TaskDetail>) {
live = { ...live, ...patch } as TaskDetail;
},
};
}
async function failAtExecute(executor: TaskExecutor, taskSnapshot: TaskDetail) {
await (executor as any).handleGraphFailure(taskSnapshot, {
disposition: "failed",
outcome: "failure",
visitedNodeIds: ["execute"],
context: { "node:execute:value": "implementation-incomplete" },
});
}
describe("execute requeue loop guard", () => {
it("terminalizes unchanged todo execute self-requeues and preserves progress", async () => {
const h = harness(task({
id: "FN-7863-TODO",
column: "todo",
steps: [
{ name: "Preflight", status: "done" },
{ name: "Implement", status: "in-progress" },
],
currentStep: 1,
}));
for (let i = 0; i < MAX_EXECUTE_REQUEUE_LOOP_CYCLES; i += 1) {
await failAtExecute(h.executor, h.live);
}
expect(h.store.updateTask).toHaveBeenCalledWith(
"FN-7863-TODO",
expect.objectContaining({
status: "failed",
error: expect.stringMatching(/^EXECUTION_DISPATCH_LOOP_EXHAUSTED:/),
executeRequeueLoopCount: MAX_EXECUTE_REQUEUE_LOOP_CYCLES,
}),
undefined,
);
expect(h.store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "task:execution-dispatch-loop-terminalized",
metadata: expect.objectContaining({
taskId: "FN-7863-TODO",
cycleCount: MAX_EXECUTE_REQUEUE_LOOP_CYCLES,
maxCycles: MAX_EXECUTE_REQUEUE_LOOP_CYCLES,
failureValue: "implementation-incomplete",
}),
}));
expect(h.store.moveTask).not.toHaveBeenCalled();
const terminalUpdate = h.store.updateTask.mock.calls.find((call: any[]) => call[1]?.status === "failed")?.[1];
expect(terminalUpdate).not.toHaveProperty("worktree");
expect(terminalUpdate).not.toHaveProperty("branch");
expect(terminalUpdate).not.toHaveProperty("steps");
const lastLog = h.store.logEntry.mock.calls.at(-1)?.[1] as string;
expect(lastLog).toMatch(/^EXECUTION_DISPATCH_LOOP_EXHAUSTED:/);
expect(lastLog).not.toContain("executor recovery preserved");
});
it("terminalizes the stale in-progress self-requeue marker path", async () => {
const h = harness(task({ id: "FN-7863-STALE", column: "in-progress" }));
(h.executor as any).graphRouting.add("FN-7863-STALE");
(h.executor as any).markGraphExecuteSelfRequeued("FN-7863-STALE");
for (let i = 0; i < MAX_EXECUTE_REQUEUE_LOOP_CYCLES; i += 1) {
await failAtExecute(h.executor, h.live);
}
expect(h.store.updateTask).toHaveBeenCalledWith(
"FN-7863-STALE",
expect.objectContaining({ status: "failed", error: expect.stringMatching(/^EXECUTION_DISPATCH_LOOP_EXHAUSTED:/) }),
undefined,
);
});
it("resets the streak on real step progress and never terminalizes", async () => {
const h = harness(task({ id: "FN-7863-PROGRESS", column: "todo" }));
for (let i = 0; i < MAX_EXECUTE_REQUEUE_LOOP_CYCLES + 3; i += 1) {
h.setLive({ currentStep: i, steps: [{ name: `Step ${i}`, status: i % 2 === 0 ? "pending" : "in-progress" }] as any });
await failAtExecute(h.executor, h.live);
}
expect(h.store.updateTask).not.toHaveBeenCalledWith(
"FN-7863-PROGRESS",
expect.objectContaining({ status: "failed" }),
expect.anything(),
);
expect(h.live.executeRequeueLoopCount).toBe(1);
});
it("emits a visible warning at the threshold without terminalizing", async () => {
const h = harness(task({ id: "FN-7863-WARN", column: "todo" }));
for (let i = 0; i < EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD; i += 1) {
await failAtExecute(h.executor, h.live);
}
expect(h.store.logEntry).toHaveBeenCalledWith(
"FN-7863-WARN",
expect.stringContaining(`Execution dispatch loop building: ${EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD}/${MAX_EXECUTE_REQUEUE_LOOP_CYCLES}`),
undefined,
undefined,
);
expect(h.store.updateTask).not.toHaveBeenCalledWith(
"FN-7863-WARN",
expect.objectContaining({ status: "failed" }),
expect.anything(),
);
});
it.each([
["userPaused", { userPaused: true }],
["paused", { paused: true }],
])("does not terminalize %s tasks from the benign branch", async (_label, patch) => {
const h = harness(task({ id: `FN-7863-${_label}`, column: "todo", ...patch }));
for (let i = 0; i < MAX_EXECUTE_REQUEUE_LOOP_CYCLES; i += 1) {
await failAtExecute(h.executor, h.live);
}
expect(h.store.updateTask).not.toHaveBeenCalledWith(
h.live.id,
expect.objectContaining({ status: "failed" }),
expect.anything(),
);
expect(h.store.logEntry).toHaveBeenCalledWith(
h.live.id,
expect.stringContaining("paused awaiting explicit unpause"),
undefined,
undefined,
);
});
});

View File

@@ -490,6 +490,10 @@ const MAX_WORKFLOW_STEP_RETRIES = 3;
const MAX_TASK_DONE_SESSION_RETRIES = 3;
/** Maximum todo requeues after exhausting in-session fn_task_done retries. */
const MAX_TASK_DONE_REQUEUE_RETRIES = 3;
/** Maximum no-progress execute-node self-requeues before terminalizing the loop. */
export const MAX_EXECUTE_REQUEUE_LOOP_CYCLES = 6;
/** Low-water mark for surfacing a visible warning before loop terminalization. */
export const EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD = 3;
/**
* Maximum bounded retries for the narrow resume-after-restart graph transient.
* Budget exhaustion falls through to terminal status:"failed" so FN-5704's
@@ -506,6 +510,13 @@ const LOOP_COMPACTION_TIMEOUT_MS = 60_000;
const TASK_DONE_REFUSAL_SUFFIX = "Either finish the work and resubmit, or do not call fn_task_done — exit the session and the engine will requeue.";
export function buildExecuteRequeueLoopSignature(live: TaskDetail): string {
return JSON.stringify({
currentStep: live.currentStep ?? null,
steps: live.steps?.map((step) => step.status) ?? [],
});
}
const TRANSIENT_WORKTREE_TASK_JSON_ENOENT_PATTERN = /ENOENT:\s+no such file or directory,\s+open\s+'([^']+\/\.fusion\/tasks\/([^/]+)\/task\.json)'/;
export function isTransientMissingTaskJsonError(error: unknown, task: Pick<Task, "id" | "worktree">): boolean {
@@ -8935,7 +8946,57 @@ export class TaskExecutor {
FNXC:WorkflowLifecycle 2026-06-23-21:19:
Also honor the in-process self-requeue marker. Upgrade/restart races and minimal stores can return a stale `in-progress` live row even after the inner executor already moved the task to `todo`; stale reads must not strand progressing tasks in review.
FNXC:WorkflowLifecycle 2026-07-12-00:00:
FN-7863: the scheduler's wall-clock dispatchStormCount guard only increments when re-dispatches happen inside its short window; slow execute→pause-abort→todo loops reset that counter every cycle. Count this funnel by execution-progress signature instead, warn early for board-visible monitoring, and terminalize only non-paused live tasks after the bounded no-progress cap while preserving worktree/branch/step progress.
*/
const signature = buildExecuteRequeueLoopSignature(live);
const nextCount = live.executeRequeueLoopSignature === signature
? (live.executeRequeueLoopCount ?? 0) + 1
: 1;
if (live.executeRequeueLoopCount !== nextCount || live.executeRequeueLoopSignature !== signature) {
await this.store.updateTask(task.id, {
executeRequeueLoopCount: nextCount,
executeRequeueLoopSignature: signature,
}, this.getRunContextFor(task.id));
}
if (nextCount === EXECUTE_REQUEUE_LOOP_VISIBLE_THRESHOLD) {
const warningMessage = `Execution dispatch loop building: ${nextCount}/${MAX_EXECUTE_REQUEUE_LOOP_CYCLES} no-progress execute re-queues`;
executorLog.warn(`${task.id}: ${warningMessage}`);
await this.store.logEntry(task.id, warningMessage, undefined, this.getRunContextFor(task.id));
}
const canTerminalizeExecuteLoop = live.userPaused !== true
&& live.paused !== true
&& live.column !== "done"
&& live.column !== "archived";
if (nextCount >= MAX_EXECUTE_REQUEUE_LOOP_CYCLES && canTerminalizeExecuteLoop) {
const terminalError = `EXECUTION_DISPATCH_LOOP_EXHAUSTED: execute node re-queued task to todo ${nextCount} times with no forward progress (last value=${failureValue ?? "no-value"}). No further automatic retries will run. Manually retry, decompose, or rescope the task.`;
await this.store.updateTask(task.id, {
status: "failed",
error: terminalError,
executeRequeueLoopCount: nextCount,
executeRequeueLoopSignature: signature,
}, this.getRunContextFor(task.id));
await this.store.recordRunAuditEvent?.({
taskId: task.id,
agentId: "executor",
runId: generateSyntheticRunId("execution-dispatch-loop", task.id),
domain: "database",
mutationType: "task:execution-dispatch-loop-terminalized",
target: task.id,
metadata: {
taskId: task.id,
cycleCount: nextCount,
maxCycles: MAX_EXECUTE_REQUEUE_LOOP_CYCLES,
progressSignature: signature,
failureValue: failureValue ?? null,
},
});
executorLog.warn(`${task.id}: ${terminalError}`);
await this.store.logEntry(task.id, terminalError, undefined, this.getRunContextFor(task.id));
await this.persistTokenUsage(task.id);
return;
}
const benignMessage = `Workflow graph execute node ended after executor re-queued task to todo (${failureValue ?? "no-value"}) — executor recovery preserved`;
executorLog.log(`${task.id}: ${benignMessage}`);
await this.store.logEntry(task.id, benignMessage, undefined, this.getRunContextFor(task.id));
@@ -10253,7 +10314,7 @@ export class TaskExecutor {
}
// Reset retry counters on success
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null, executeRequeueLoopCount: null, executeRequeueLoopSignature: null });
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after step-session completion")) {
return;
}
@@ -11085,7 +11146,7 @@ export class TaskExecutor {
}
// Reset retry counters on success
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null, executeRequeueLoopCount: null, executeRequeueLoopSignature: null });
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion (post-reset)")) {
return;
}
@@ -11369,7 +11430,7 @@ export class TaskExecutor {
await this.store.logEntry(task.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(task.id));
}
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null, executeRequeueLoopCount: null, executeRequeueLoopSignature: null });
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion retry")) {
return;
}

View File

@@ -551,6 +551,8 @@ export type DatabaseMutationType =
| "task:stuck-no-progress-churn-terminalized"
/** Metadata: { taskId, cycleCount, windowMs, lastMoveSource } */
| "task:dispatch-oscillation-terminalized"
/** Metadata: { taskId, cycleCount, maxCycles, progressSignature, failureValue } */
| "task:execution-dispatch-loop-terminalized"
| "task:auto-recover-starved-refinement"
/** Metadata: { rawDiffFileCount: number; attributedFileCount: number; foreignCommitCount: number; foreignCommitShas: string[]; source: string } */
| "task:worktree-contamination-detected"

View File

@@ -766,10 +766,12 @@ export class Scheduler {
}
} else if (to === "in-review" || to === "done" || to === "archived") {
this.recentEngineTodoRequeues.delete(task.id);
if (task.dispatchStormCount != null || task.lastDispatchAt != null) {
if (task.dispatchStormCount != null || task.lastDispatchAt != null || task.executeRequeueLoopCount != null || task.executeRequeueLoopSignature != null) {
void this.store.updateTask(task.id, {
dispatchStormCount: null,
lastDispatchAt: null,
executeRequeueLoopCount: null,
executeRequeueLoopSignature: null,
}).catch((error) => {
schedulerLog.warn(`Failed to reset dispatch oscillation state for ${task.id} on move to ${to}: ${error instanceof Error ? error.message : String(error)}`);
});
@@ -812,10 +814,12 @@ export class Scheduler {
} else if (this.pausedTaskIds.has(task.id)) {
// Task was paused, now unpaused — trigger scheduling
this.pausedTaskIds.delete(task.id);
if (task.userPaused === false && (task.dispatchStormCount != null || task.lastDispatchAt != null)) {
if (task.userPaused === false && (task.dispatchStormCount != null || task.lastDispatchAt != null || task.executeRequeueLoopCount != null || task.executeRequeueLoopSignature != null)) {
void this.store.updateTask(task.id, {
dispatchStormCount: null,
lastDispatchAt: null,
executeRequeueLoopCount: null,
executeRequeueLoopSignature: null,
}).catch((error) => {
schedulerLog.warn(`Failed to reset dispatch oscillation state for ${task.id} on unpause: ${error instanceof Error ? error.message : String(error)}`);
});