kestra-io/kestra · error · IllegalArgumentException

You can only change the state of a task run for a terminated

Error message

You can only change the state of a task run for a terminated non killed execution.

What it means

ExecutionService.changeTaskRunState only allows changing a task run's state when the containing execution is in a terminal state that is not KILLED. The guard is State.canChangeStatus(), defined as isTerminated() && !isKilled() (State.java:189-190). Terminated types include FAILED, WARNING, SUCCESS, CANCELLED, RETRIED, SKIPPED, RESUBMITTED — KILLED is explicitly excluded because a killed execution must not be revived by editing a single task run. The check runs AFTER markAs already mutated state, so the exception discards that in-memory work.

Source

Thrown at core/src/main/java/io/kestra/core/services/ExecutionService.java:554

        if (targetExecution.getState().canChangeStatus()) {
            List<TaskRun> newTaskRuns = newExecution.getTaskRunList();
            // We need to remove global error tasks and flowable error tasks if any
            flow
                .allErrorsWithChildren()
                .forEach(task -> newTaskRuns.removeIf(taskRun -> taskRun.getTaskId().equals(task.getId())));

            // We need to remove global finally tasks and flowable error tasks if any
            flow
                .allFinallyWithChildren()
                .forEach(task -> newTaskRuns.removeIf(taskRun -> taskRun.getTaskId().equals(task.getId())));

            // We need to remove afterExecution tasks
            ListUtils.emptyOnNull(flow.getAfterExecution())
                .forEach(task -> newTaskRuns.removeIf(taskRun -> taskRun.getTaskId().equals(task.getId())));

            newExecution = newExecution.withTaskRunList(newTaskRuns);
        } else {
            throw new IllegalArgumentException("You can only change the state of a task run for a terminated non killed execution.");
        }

        eventPublisher.publishEvent(CrudEvent.of(targetExecution, newExecution));
        return newExecution;
    }

    /**
     * Find the execution (main or loop sub-execution) that contains the given task run.
     * Searches the given execution first; if not found, searches loop sub-executions.
     *
     * @param execution the parent execution to search first
     * @param taskRunId the task run ID to find
     * @return the execution and task run pair, or empty if not found in any execution
     */
    public Optional<ExecutionWithTaskRun> findExecutionWithTaskRun(Execution execution, String taskRunId) {
        Optional<TaskRun> maybeTaskRun = ListUtils.emptyOnNull(execution.getTaskRunList()).stream()
            .filter(tr -> tr.getId().equals(taskRunId))
            .findFirst();

View on GitHub (pinned to 823fada927)

Solutions

  1. Before calling changeTaskRunState, fetch the latest execution and assert execution.getState().canChangeStatus() (terminated and not KILLED). Reject or wait-and-retry if false.
  2. If the execution is KILLED, do not use changeTaskRunState — restart or replay the execution via ExecutionService.restart or the restart API instead.
  3. If the execution is still running, wait for it to reach a terminal state (poll execution state, or subscribe to the execution queue) before issuing the change.
  4. Guard the UI/API call site with a 409 Conflict response when canChangeStatus() is false, so clients get a clear signal instead of a 500 from the IllegalArgumentException.

Example fix

// before
executionService.changeTaskRunState(execution, flow, taskRunId, State.Type.SUCCESS);

// after
if (!execution.getState().canChangeStatus()) {
    throw new IllegalStateException(
        "Cannot change task run state: execution is %s.".formatted(execution.getState().getCurrent())
    );
}
executionService.changeTaskRunState(execution, flow, taskRunId, State.Type.SUCCESS);
Defensive patterns

Strategy: validation

Validate before calling

import io.kestra.core.models.executions.Execution;

Execution fresh = executionRepository.findById(tenantId, executionId).orElseThrow();
if (!fresh.getState().canChangeStatus()) {
    throw new IllegalStateException(
        "Execution %s is in state %s; changeTaskRunState requires a terminated, non-killed execution.".formatted(
            fresh.getId(), fresh.getState().getCurrent())
    );
}
executionService.changeTaskRunState(fresh, flow, taskRunId, newState);

Type guard

// Java guard helper — gate any changeTaskRunState call through this.
public static boolean canChangeTaskRunState(Execution execution) {
    return execution != null && execution.getState().canChangeStatus();
}

Try / catch

try {
    executionService.changeTaskRunState(execution, flow, taskRunId, newState);
} catch (IllegalArgumentException e) {
    // re-fetch, surface a 409 Conflict with the current execution state,
    // and do NOT retry unchanged — the state machine must be reconciled first.
    throw new ConflictException("Execution %s cannot have its task run state changed: %s".formatted(
        execution.getId(), e.getMessage()));
}

Prevention

When it happens

Trigger: Calling the change-task-run-state API (or ExecutionService.changeTaskRunState) on an execution whose current State.Type is KILLED, or on a non-terminated execution such as RUNNING, CREATED, PAUSED, QUEUED, RETRYING, or BREAKPOINT. Common entry points: the UI 'change state' action on a task run, a REST call to the execution controller, or a programmatic call while the execution is still being processed by the executor/worker.

Common situations: Editing a task run on an execution that is still RUNNING (worker hasn't finished); editing after the execution was killed via the kill API; a race where the execution transitions between the time the UI fetched it and the time the change request is sent; retrying automation that does not re-check execution state before issuing the change.

Related errors


AI-assisted analysis of kestra-io/kestra@823fada927 (2026-08-14). Data as JSON: /api/errors/409dd0560b2819e7. Report an issue: GitHub.