Durable execution

Use waits, subtasks, handles, commits, state-machine steps, map/reduce, branches, and ledger views without breaking replay.

Constal records runtime operations at stable journal positions. A resumed invocation replays the recorded result, continues the owned operation, or applies its declared recovery contract.

Execution limits and recovery

A Run can span many invocations. Reaching an invocation's execution deadline does not erase its committed progress: unfinished work is handed to a fresh invocation. If execution is interrupted unexpectedly, durable scheduling and lease recovery resume the Run from its recorded journal. Completed operations replay their receipts; an interrupted external operation follows its declared recovery contract rather than being blindly repeated.

Limitation: durability does not make an individual operation unbounded. If the same operation repeatedly exceeds its CPU or memory allowance, retrying it with the same code and inputs cannot solve the problem. That operation needs to be changed before the Run can make progress. Constal does not automatically split arbitrary code, enlarge its limits, or treat resource exhaustion as successful execution.

Run-level budgets, explicit timeouts, cancellation, and Policy still apply. Invocation recovery does not override them.

Wait and delegate

For a new invocation after the current Run finishes, use Session-owned schedules. Timed ctx.await() resumes the existing Run; ctx.schedule() records future work with its own Run lifecycle.

In SDK 4.0, timeout and maxBytes are optional caller limits. Without timeout, the wait stays open until it is resolved or cancelled. Without maxBytes, the platform does not impose a response-size quota. Supplied values are honored without clamping; Run budgets, Policy, and provider limits still apply. Large replies use the existing artifact storage path.

ts
import { all } from "@constal/std";

const approval = ctx.await<{ approved: boolean }>("approval", {
  timeout: 86_400_000,
  onTimeout: { approved: false },
  schema: { type: "object", required: ["approved"], properties: { approved: { type: "boolean" } } },
  maxBytes: 1_024,
});

const checks = [
  ctx.spawn(riskCheck, input, {
    retries: 2,
    budget: { turns: 4, microUsd: 250_000, wallMs: 60_000 },
  }),
  ctx.spawn(complianceCheck, input, {
    retries: 2,
    budget: { turns: 4, microUsd: 250_000, wallMs: 60_000 },
  }),
];

const [decision, [risk, compliance]] = await Promise.all([approval, all(checks)]);

Handles have stable ids and recorded terminal outcomes. Do not catch and suppress runtime suspension, and do not reuse Ctx or a Handle after the invocation yields. all, select, and race are derived joins from @constal/std, not additional Agent primitives.

Each spawn creates an independently leased child Run inside the current Session; it does not create another Session. Distinct child Runs can execute concurrently while the Session remains the single writer that validates and commits their fenced responses. A slow child therefore does not prevent an independent sibling from starting or completing, and one failed dispatch retries without discarding successful siblings. Use a new Session only for a separate durable mission or conversation. Child Agents that mutate one shared workspace still need application-level ordering or isolated worktrees; concurrency does not make conflicting effects safe.

Map and reduce registered work

ts
import { agent, validateFoldFn, validatePartitionFn } from "@constal/sdk";

const extract = validatePartitionFn<string, { words: number }, never>({
  id: "word-count", version: "1", effects: "pure", batch: { rows: 100 },
  async run(batch, out) {
    for (const text of batch) out.emit({ words: text.trim().split(/\s+/u).length });
  },
});

const total = validateFoldFn<{ words: number }, { words: number }>({
  id: "sum-words", version: "1", deterministic: true, associative: true,
  async run(group, out) {
    let words = 0;
    for await (const row of group.rows) words += row.words;
    out.emit({ words });
  },
});

export default agent({
  id: "counter", version: "1.0.0", model: "model",
  partitionFns: [extract], foldFns: [total],
  async onMessage(rows: string[], ctx) {
    const mapped = await ctx.map(extract, rows, { partition: { rows: 100 } });
    return ctx.reduce(total, mapped, { scope: "global" });
  },
});

Stage functions must be registered and versioned. Large data moves by content reference; failed partitions do not erase earlier facts.

Durable mode

Set mode: "durable" and implement init, step, and output when progress itself must be explicit state:

ts
import { agent } from "@constal/sdk";

type State = { input: unknown; phase: "review" | "done" | "rejected" };

export default agent<State>({
  id: "review-workflow", version: "1.0.0", model: "model", mode: "durable",
  init: (input) => ({ input, phase: "review" }),
  async step(state, ctx) {
    if (state.phase === "review") {
      const decision = await ctx.await<{ approved: boolean }>("approval", {
        schema: { type: "object", required: ["approved"], additionalProperties: false,
          properties: { approved: { type: "boolean" } } },
      });
      return { state: { ...state, phase: decision.approved ? "done" : "rejected" }, done: true };
    }
    return { state, done: true };
  },
  output: (state) => state,
});

Each step() must return serializable state and a done flag. Operators resolve waits and apply pause, resume, interrupt, cancel, Policy, rebind, truncate, or branch through authenticated Platform API events—not Agent SDK calls. See Run operations and The seven Agent primitives.