diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/planner/PlanExecutionLimits.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/planner/PlanExecutionLimits.java
new file mode 100644
index 000000000..59fb683fc
--- /dev/null
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/planner/PlanExecutionLimits.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.graph.agent.planner;
+
+import java.time.Duration;
+
+/**
+ * Bounds of one plan run. Every run is bounded by attempts, replans and the total step
+ * execution budget; the wall-clock deadline is optional and measured from the start of
+ * the run, including time spent waiting for external input.
+ *
+ * @param maxParallelism maximum ready steps dispatched together in one local batch
+ * @param maxStepAttempts maximum attempts of one step identity, including the first
+ * @param maxReplans maximum replanning attempts, valid or not
+ * @param maxStepExecutions total step dispatches allowed in the run (the budget)
+ * @param deadline optional wall-clock deadline of the run, or {@code null}
+ */
+public record PlanExecutionLimits(int maxParallelism, int maxStepAttempts, int maxReplans, int maxStepExecutions,
+ Duration deadline) {
+
+ public static final int DEFAULT_MAX_PARALLELISM = 4;
+
+ public static final int DEFAULT_MAX_STEP_ATTEMPTS = 2;
+
+ public static final int DEFAULT_MAX_REPLANS = 2;
+
+ public static final int DEFAULT_MAX_STEP_EXECUTIONS = 64;
+
+ public PlanExecutionLimits {
+ PlanValidator.require(maxParallelism > 0 && maxStepAttempts > 0 && maxReplans >= 0 && maxStepExecutions > 0,
+ "Plan execution limits must be positive");
+ PlanValidator.require(deadline == null || (!deadline.isNegative() && !deadline.isZero()),
+ "Plan deadline must be positive");
+ }
+
+ public static PlanExecutionLimits defaults() {
+ return new PlanExecutionLimits(DEFAULT_MAX_PARALLELISM, DEFAULT_MAX_STEP_ATTEMPTS, DEFAULT_MAX_REPLANS,
+ DEFAULT_MAX_STEP_EXECUTIONS, null);
+ }
+
+ public PlanExecutionLimits withDeadline(Duration deadline) {
+ return new PlanExecutionLimits(this.maxParallelism, this.maxStepAttempts, this.maxReplans,
+ this.maxStepExecutions, deadline);
+ }
+
+}
diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/planner/PlanExecutor.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/planner/PlanExecutor.java
new file mode 100644
index 000000000..307b581ae
--- /dev/null
+++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/planner/PlanExecutor.java
@@ -0,0 +1,851 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.graph.agent.planner;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ForkJoinPool;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import io.github.agentic.ai.graph.CompileConfig;
+import io.github.agentic.ai.graph.CompiledGraph;
+import io.github.agentic.ai.graph.KeyStrategy;
+import io.github.agentic.ai.graph.KeyStrategyFactoryBuilder;
+import io.github.agentic.ai.graph.OverAllState;
+import io.github.agentic.ai.graph.RunnableConfig;
+import io.github.agentic.ai.graph.StateGraph;
+import io.github.agentic.ai.graph.action.AsyncNodeActionWithConfig;
+import io.github.agentic.ai.graph.action.InterruptableAction;
+import io.github.agentic.ai.graph.action.InterruptionMetadata;
+import io.github.agentic.ai.graph.agent.interceptor.ToolOutcome;
+import io.github.agentic.ai.graph.exception.GraphStateException;
+import com.networknt.schema.Schema;
+import tools.jackson.databind.JsonNode;
+
+import static io.github.agentic.ai.graph.StateGraph.END;
+import static io.github.agentic.ai.graph.StateGraph.START;
+import static io.github.agentic.ai.graph.action.AsyncEdgeAction.edge_async;
+import static io.github.agentic.ai.graph.action.AsyncNodeActionWithConfig.node_async;
+
+/**
+ * Opt-in Plan-and-Execute runtime on top of {@link StateGraph}. A validated
+ * {@link PlanSpec} is executed by a small fixed graph:
+ *
+ *
+ *
+ * Each {@code plan_execute} pass dispatches the ready steps (all dependencies completed)
+ * of the current revision as one local, bounded parallel batch and records their outcomes
+ * in the {@link PlanRun} ledger. A downstream step is never dispatched before its
+ * dependencies completed. Failures are routed by the {@link PlanFailurePolicy} to retry,
+ * bounded replanning, an input interruption or a terminal stop.
+ *
+ *
+ * This is a whole-run, in-process executor: node boundaries checkpoint the ledger through
+ * the configured saver, and {@code plan_input} uses the standard graph interruption so a
+ * run suspended with {@link PlanRunStatus#NEED_INPUT} resumes from its checkpoint.
+ * Durable commit/journal semantics, cross-instance workers and sub-agent protocols remain
+ * with their dedicated components; step effects stay behind the application's
+ * {@link PlanStepExecutor} and its existing authorization gates.
+ *
+ */
+public final class PlanExecutor {
+
+ /** State key holding the JSON-encoded {@link PlanRun}. */
+ public static final String RUN_KEY = "argi_plan_run";
+
+ /** State key holding JSON-encoded input supplied for the awaited step. */
+ public static final String INPUT_KEY = "argi_plan_input";
+
+ public static final String EXECUTE_NODE = "plan_execute";
+
+ public static final String REPLAN_NODE = "plan_replan";
+
+ public static final String INPUT_NODE = "plan_input";
+
+ /** Interruption metadata key carrying the awaited step ID. */
+ public static final String INTERRUPT_STEP_ID = "planStepId";
+
+ /** Interruption metadata key carrying the input request. */
+ public static final String INTERRUPT_PROMPT = "planPrompt";
+
+ /** Interruption metadata key carrying the reason code. */
+ public static final String INTERRUPT_REASON = "planReasonCode";
+
+ private final PlanValidator validator;
+
+ private final PlanStepExecutor stepExecutor;
+
+ private final PlanReplanner replanner;
+
+ private final PlanFailurePolicy failurePolicy;
+
+ private final PlanExecutionLimits limits;
+
+ private final Clock clock;
+
+ private final Executor executor;
+
+ private PlanExecutor(Builder builder) {
+ this.validator = builder.validator;
+ this.stepExecutor = builder.stepExecutor;
+ this.replanner = builder.replanner;
+ this.failurePolicy = builder.failurePolicy;
+ this.limits = builder.limits;
+ this.clock = builder.clock;
+ this.executor = builder.executor;
+ }
+
+ public static Builder builder(PlanValidator validator, PlanStepExecutor stepExecutor) {
+ return new Builder(validator, stepExecutor);
+ }
+
+ /**
+ * Create the initial state of a new run. A new run cannot claim progress: the plan
+ * must pass {@link PlanValidator#validateGenerated(PlanSpec)}.
+ * @param plan the validated initial revision
+ * @return the state to pass to the compiled graph
+ */
+ public Map initialState(PlanSpec plan) {
+ this.validator.validateGenerated(plan);
+ Ledger ledger = new Ledger(plan, this.clock.millis());
+ return Map.of(RUN_KEY, encode(ledger.toRun()));
+ }
+
+ /**
+ * State update that supplies input for the awaited step, for use with
+ * {@link RunnableConfig.Builder#addStateUpdate(Map)} on resume or
+ * {@link CompiledGraph#updateState(RunnableConfig, Map)}.
+ * @param stepId the awaited step
+ * @param jsonInput JSON-encoded input
+ * @return the state update
+ */
+ public static Map input(String stepId, String jsonInput) {
+ PlanValidator.text(stepId, "stepId");
+ return Map.of(INPUT_KEY, PlanCodec.MAPPER.writeValueAsString(new ProvidedInput(stepId, jsonInput)));
+ }
+
+ /** Read the run ledger from graph state. */
+ public static PlanRun read(OverAllState state) {
+ Objects.requireNonNull(state, "state");
+ return read(state.data());
+ }
+
+ /** Read the run ledger from graph state data. */
+ public static PlanRun read(Map state) {
+ Object value = state == null ? null : state.get(RUN_KEY);
+ PlanValidator.require(value instanceof String, "State does not contain a plan run");
+ try {
+ return PlanCodec.MAPPER.readValue((String) value, PlanRun.class);
+ }
+ catch (RuntimeException ex) {
+ throw new PlanValidationException("Invalid plan run state", ex);
+ }
+ }
+
+ /**
+ * Graph iterations needed by one invocation within the configured limits: each
+ * execution pass consumes budget, each replan consumes a replanning attempt.
+ */
+ public int recursionLimit() {
+ return 2 * this.limits.maxStepExecutions() + 2 * this.limits.maxReplans() + 4;
+ }
+
+ /** Build the uncompiled executor graph. */
+ public StateGraph graph() throws GraphStateException {
+ var keys = new KeyStrategyFactoryBuilder().defaultStrategy(KeyStrategy.REPLACE)
+ .addStrategy(RUN_KEY, KeyStrategy.REPLACE)
+ .addStrategy(INPUT_KEY, KeyStrategy.REPLACE)
+ .build();
+ Map routes = Map.of(EXECUTE_NODE, EXECUTE_NODE, REPLAN_NODE, REPLAN_NODE, INPUT_NODE,
+ INPUT_NODE, END, END);
+ return new StateGraph("argi-plan-executor", keys)
+ .addNode(EXECUTE_NODE, node_async((state, config) -> execute(state)))
+ .addNode(REPLAN_NODE, node_async((state, config) -> replan(state)))
+ .addNode(INPUT_NODE, new InputNode())
+ .addEdge(START, EXECUTE_NODE)
+ .addConditionalEdges(EXECUTE_NODE, edge_async(PlanExecutor::route), routes)
+ .addConditionalEdges(REPLAN_NODE, edge_async(PlanExecutor::route), routes)
+ .addConditionalEdges(INPUT_NODE, edge_async(PlanExecutor::route), routes);
+ }
+
+ /**
+ * Compile the executor graph, raising the recursion limit to
+ * {@link #recursionLimit()} when the given configuration is lower.
+ */
+ public CompiledGraph compile(CompileConfig config) throws GraphStateException {
+ CompileConfig base = config == null ? CompileConfig.builder().build() : config;
+ return graph().compile(CompileConfig.builder(base)
+ .recursionLimit(Math.max(base.recursionLimit(), recursionLimit()))
+ .build());
+ }
+
+ private static String route(OverAllState state) {
+ PlanRun run = read(state);
+ if (run.status() == PlanRunStatus.NEED_INPUT) {
+ return INPUT_NODE;
+ }
+ if (run.terminal()) {
+ return END;
+ }
+ return run.failedStepIds().isEmpty() ? EXECUTE_NODE : REPLAN_NODE;
+ }
+
+ // ---------------------------------------------------------------- execution
+
+ private Map execute(OverAllState state) {
+ Ledger ledger = new Ledger(read(state));
+ if (ledger.status != PlanRunStatus.RUNNING || !ledger.failedStepIds.isEmpty()) {
+ return Map.of();
+ }
+ if (deadlinePassed(ledger)) {
+ return write(ledger.terminate(PlanRunStatus.DEADLINE_EXCEEDED, PlanReasonCodes.DEADLINE_EXCEEDED, null));
+ }
+ List ready = ledger.readySteps();
+ if (ready.isEmpty()) {
+ return write(ledger.allCompleted()
+ ? ledger.terminate(PlanRunStatus.COMPLETED, PlanReasonCodes.PLAN_COMPLETED, null)
+ : ledger.terminate(PlanRunStatus.FAILED, PlanReasonCodes.NO_READY_STEPS, null));
+ }
+ int budget = this.limits.maxStepExecutions() - ledger.stepExecutions;
+ if (budget <= 0) {
+ return write(ledger.terminate(PlanRunStatus.BUDGET_EXHAUSTED, PlanReasonCodes.BUDGET_EXHAUSTED, null));
+ }
+ List batch = ready.subList(0, Math.min(ready.size(), Math.min(budget, this.limits.maxParallelism())));
+
+ Map> running = new LinkedHashMap<>();
+ Map attempts = new LinkedHashMap<>();
+ for (PlanStep step : batch) {
+ ledger.stepExecutions++;
+ int attempt = ledger.attempts.merge(step.stepId(), 1, Integer::sum);
+ attempts.put(step.stepId(), attempt);
+ PlanStepRequest request = new PlanStepRequest(ledger.objective(), ledger.revision(), step,
+ stepKey(step), attempt, ledger.dependencyResults(step), ledger.inputs.get(step.stepId()));
+ running.put(step.stepId(), CompletableFuture.supplyAsync(() -> invoke(request), this.executor));
+ }
+
+ boolean deadlineHit = false;
+ Map outcomes = new LinkedHashMap<>();
+ for (Map.Entry> entry : running.entrySet()) {
+ PlanStepOutcome outcome;
+ try {
+ Long remaining = remainingMillis(ledger);
+ outcome = remaining == null ? entry.getValue().get()
+ : entry.getValue().get(Math.max(0, remaining), TimeUnit.MILLISECONDS);
+ }
+ catch (TimeoutException ex) {
+ entry.getValue().cancel(true);
+ deadlineHit = true;
+ outcome = PlanStepOutcome.failed(ToolOutcome.TIMEOUT, "Plan deadline passed while the step was running");
+ }
+ catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ entry.getValue().cancel(true);
+ outcome = PlanStepOutcome.failed(ToolOutcome.CANCELLED, "Plan execution was interrupted");
+ }
+ catch (ExecutionException ex) {
+ outcome = PlanStepOutcome.failed(ToolOutcome.ERROR, describe(ex.getCause()));
+ }
+ outcomes.put(entry.getKey(), outcome);
+ }
+
+ Decision decision = null;
+ for (PlanStep step : batch) {
+ PlanStepOutcome outcome = outcomes.get(step.stepId());
+ if (outcome.effectReference() != null) {
+ ledger.effectReferences.put(step.stepId(), outcome.effectReference());
+ }
+ Decision next = switch (outcome.kind()) {
+ case SUCCEEDED -> {
+ String problem = outputProblem(step, outcome.result());
+ if (problem == null) {
+ ledger.complete(step, outcome.result());
+ yield null;
+ }
+ yield decide(step, ToolOutcome.ERROR, problem, attempts.get(step.stepId()),
+ outcome.result() == null ? PlanReasonCodes.STEP_RESULT_MISSING
+ : PlanReasonCodes.STEP_OUTPUT_INVALID);
+ }
+ case NEED_INPUT -> new Decision(PlanFailurePolicy.Action.NEED_INPUT, step, outcome.error(),
+ PlanReasonCodes.NEED_INPUT);
+ case FAILED -> decide(step, outcome.outcome(), outcome.error(), attempts.get(step.stepId()),
+ PlanReasonCodes.STEP_FAILED);
+ };
+ if (next != null && next.action() == PlanFailurePolicy.Action.REPLAN) {
+ ledger.fail(step, next.detail());
+ }
+ if (next != null && next.action() == PlanFailurePolicy.Action.STOP) {
+ ledger.fail(step, next.detail());
+ }
+ if (next != null && (decision == null || priority(next.action()) > priority(decision.action()))) {
+ decision = next;
+ }
+ }
+
+ if (deadlineHit) {
+ ledger.failedStepIds.clear();
+ return write(ledger.terminate(PlanRunStatus.DEADLINE_EXCEEDED, PlanReasonCodes.DEADLINE_EXCEEDED, null));
+ }
+ if (decision == null || decision.action() == PlanFailurePolicy.Action.RETRY) {
+ return write(ledger.allCompleted()
+ ? ledger.terminate(PlanRunStatus.COMPLETED, PlanReasonCodes.PLAN_COMPLETED, null) : ledger);
+ }
+ return write(switch (decision.action()) {
+ case STOP -> {
+ ledger.failedStepIds.clear();
+ yield ledger.terminate(PlanRunStatus.FAILED, decision.reasonCode(),
+ decision.step().stepId() + ": " + decision.detail());
+ }
+ case REPLAN -> ledger.mark(PlanRunStatus.RUNNING, decision.reasonCode(),
+ decision.step().stepId() + ": " + decision.detail());
+ case NEED_INPUT -> ledger.await(decision.step(), decision.detail());
+ case RETRY -> ledger;
+ });
+ }
+
+ private Decision decide(PlanStep step, String outcome, String error, int attempt, String reasonCode) {
+ PlanFailurePolicy.Action action = this.failurePolicy
+ .decide(new PlanFailurePolicy.Failure(step, outcome, error, attempt, this.limits.maxStepAttempts()));
+ if (action == null) {
+ action = PlanFailurePolicy.Action.STOP;
+ }
+ if (action == PlanFailurePolicy.Action.RETRY && attempt >= this.limits.maxStepAttempts()) {
+ action = PlanFailurePolicy.Action.REPLAN;
+ }
+ return new Decision(action, step, outcome + (error == null ? "" : " - " + error), reasonCode);
+ }
+
+ /**
+ * A batch resolves to its most severe continuation: stop, then replan (input
+ * requests of other steps are asked again in the next revision), then input, then
+ * retry. Completed steps of the batch are always kept.
+ */
+ private static int priority(PlanFailurePolicy.Action action) {
+ return switch (action) {
+ case STOP -> 3;
+ case REPLAN -> 2;
+ case NEED_INPUT -> 1;
+ case RETRY -> 0;
+ };
+ }
+
+ private PlanStepOutcome invoke(PlanStepRequest request) {
+ try {
+ PlanStepOutcome outcome = this.stepExecutor.execute(request);
+ return outcome != null ? outcome
+ : PlanStepOutcome.failed(ToolOutcome.ERROR, "Step executor returned no outcome");
+ }
+ catch (Exception ex) {
+ return PlanStepOutcome.failed(ToolOutcome.ERROR, describe(ex));
+ }
+ }
+
+ private static String outputProblem(PlanStep step, String result) {
+ if (result == null) {
+ return "Step returned no result";
+ }
+ JsonNode node;
+ try {
+ node = PlanCodec.MAPPER.readTree(result);
+ }
+ catch (RuntimeException ex) {
+ return "Step result is not valid JSON";
+ }
+ Schema schema = PlanCodec.SCHEMAS.getSchema(PlanCodec.MAPPER.readTree(step.outputSchema()));
+ return node == null || !schema.validate(node).isEmpty() ? "Step result does not match its output schema"
+ : null;
+ }
+
+ // ---------------------------------------------------------------- replanning
+
+ private Map replan(OverAllState state) {
+ Ledger ledger = new Ledger(read(state));
+ if (ledger.status != PlanRunStatus.RUNNING || ledger.failedStepIds.isEmpty()) {
+ return Map.of();
+ }
+ if (deadlinePassed(ledger)) {
+ return write(ledger.terminate(PlanRunStatus.DEADLINE_EXCEEDED, PlanReasonCodes.DEADLINE_EXCEEDED, null));
+ }
+ if (ledger.replans >= this.limits.maxReplans()) {
+ return write(ledger.terminate(PlanRunStatus.REPLAN_LIMIT_EXCEEDED, PlanReasonCodes.REPLAN_LIMIT_EXCEEDED,
+ ledger.detail));
+ }
+ if (this.replanner == null) {
+ return write(ledger.terminate(PlanRunStatus.FAILED, PlanReasonCodes.REPLAN_UNAVAILABLE, ledger.detail));
+ }
+ ledger.replans++;
+ PlanSpec current = ledger.toRun().plan();
+ PlanSpec proposed;
+ try {
+ proposed = this.replanner.replan(new PlanReplanner.Request(current, ledger.failedStepIds,
+ ledger.toRun().results(), current.revision() + 1, ledger.replans));
+ }
+ catch (Exception ex) {
+ return write(ledger.terminate(PlanRunStatus.FAILED, PlanReasonCodes.REPLAN_FAILED, describe(ex)));
+ }
+ if (proposed == null) {
+ return write(ledger.terminate(PlanRunStatus.REPLAN_REJECTED, PlanReasonCodes.REPLAN_EMPTY, null));
+ }
+ Rejection rejection = check(ledger, current, proposed);
+ if (rejection != null) {
+ return write(ledger.terminate(rejection.status(), rejection.reasonCode(), rejection.detail()));
+ }
+ return write(ledger.adopt(proposed));
+ }
+
+ private Rejection check(Ledger ledger, PlanSpec current, PlanSpec proposed) {
+ try {
+ this.validator.validateGenerated(proposed);
+ }
+ catch (PlanValidationException ex) {
+ return new Rejection(PlanRunStatus.INVALID_PLAN, PlanReasonCodes.REPLAN_INVALID, ex.getMessage());
+ }
+ if (proposed.revision() != current.revision() + 1 || !proposed.objective().equals(current.objective())) {
+ return new Rejection(PlanRunStatus.INVALID_PLAN, PlanReasonCodes.REPLAN_INVALID,
+ "Proposal must keep the objective and use revision " + (current.revision() + 1));
+ }
+ Map proposedSteps = new LinkedHashMap<>();
+ proposed.steps().forEach(step -> proposedSteps.put(step.stepId(), step));
+ for (PlanStep step : ledger.steps.values()) {
+ if (step.status() == PlanStep.Status.COMPLETED) {
+ PlanStep retained = proposedSteps.get(step.stepId());
+ if (retained == null) {
+ return new Rejection(PlanRunStatus.INVALID_PLAN, PlanReasonCodes.COMPLETED_STEP_DROPPED,
+ step.stepId());
+ }
+ if (!identity(retained).equals(identity(step))) {
+ return new Rejection(PlanRunStatus.INVALID_PLAN, PlanReasonCodes.STEP_IDENTITY_REUSED,
+ step.stepId());
+ }
+ }
+ }
+ boolean introducesWork = false;
+ boolean pendingWork = false;
+ Set failedDefinitions = new HashSet<>(ledger.failedDefinitions);
+ ledger.failedStepIds.forEach(id -> failedDefinitions.add(definition(ledger.steps.get(id))));
+ for (PlanStep step : proposed.steps()) {
+ PlanStep existing = ledger.steps.get(step.stepId());
+ if (existing != null && existing.status() == PlanStep.Status.COMPLETED) {
+ continue;
+ }
+ pendingWork = true;
+ if (ledger.retiredStepIds.contains(step.stepId()) || ledger.failedStepIds.contains(step.stepId())
+ || (existing != null && !identity(existing).equals(identity(step)))) {
+ return new Rejection(PlanRunStatus.INVALID_PLAN, PlanReasonCodes.STEP_IDENTITY_REUSED, step.stepId());
+ }
+ if (failedDefinitions.contains(definition(step))) {
+ return new Rejection(PlanRunStatus.REPLAN_REJECTED, PlanReasonCodes.REPLAN_NO_PROGRESS,
+ step.stepId());
+ }
+ introducesWork |= existing == null;
+ }
+ if (!pendingWork) {
+ return new Rejection(PlanRunStatus.REPLAN_REJECTED, PlanReasonCodes.REPLAN_EMPTY, null);
+ }
+ if (!introducesWork) {
+ return new Rejection(PlanRunStatus.REPLAN_REJECTED, PlanReasonCodes.REPLAN_NO_PROGRESS,
+ "Proposal only drops failed work");
+ }
+ return null;
+ }
+
+ // ---------------------------------------------------------------- helpers
+
+ private boolean deadlinePassed(Ledger ledger) {
+ Long remaining = remainingMillis(ledger);
+ return remaining != null && remaining <= 0;
+ }
+
+ private Long remainingMillis(Ledger ledger) {
+ if (this.limits.deadline() == null) {
+ return null;
+ }
+ return ledger.startedAtMillis + this.limits.deadline().toMillis() - this.clock.millis();
+ }
+
+ private static Map write(Ledger ledger) {
+ return Map.of(RUN_KEY, encode(ledger.toRun()));
+ }
+
+ private static String encode(PlanRun run) {
+ return PlanCodec.MAPPER.writeValueAsString(run);
+ }
+
+ private static String describe(Throwable error) {
+ if (error == null) {
+ return "Unknown failure";
+ }
+ return error.getMessage() == null ? error.getClass().getName()
+ : error.getClass().getSimpleName() + ": " + error.getMessage();
+ }
+
+ /**
+ * Stable step identity: the ID plus every field that determines what the step does.
+ */
+ static String stepKey(PlanStep step) {
+ return step.stepId() + "#" + identity(step).substring(0, 16);
+ }
+
+ private static String identity(PlanStep step) {
+ return digest(Arrays.asList(step.stepId(), step.objective(), step.dependencies(), step.executionTarget(),
+ step.inputSchema(), step.outputSchema(), step.completionCriteria()));
+ }
+
+ /** What a step does, independent of its ID and position in the graph. */
+ private static String definition(PlanStep step) {
+ return digest(Arrays.asList(step.objective(), step.executionTarget(), step.inputSchema(), step.outputSchema(),
+ step.completionCriteria()));
+ }
+
+ private static String digest(List