Interrupts & resume
Normative: MODEL.md §6.
An interrupt is a step whose value comes from a human instead of a function.
That single idea gives the whole human-in-the-loop feature set for free.
ctx.interrupt(payload):
- Look the key up in the journal. Hit → return the recorded answer.
- Miss → journal
{ key, pending, payload }, then halt the task.
Halting a task halts its superstep: the engine checkpoints (journals included),
emits an interrupt event carrying the payload, and ends the run. The thread now
has a pending interrupt. The next run for that thread must supply an answer;
the engine writes it into the journal entry and replays the task.
- TypeScript
- .NET (C#)
import { graph, channel, START, END, run, resume, InMemoryCheckpointer } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";
/** Support state — one append-only channel of messages. */
const SupportState = {
messages: channel.append<string>(),
} satisfies ChannelMap;
type SupportState = StateOf<typeof SupportState>;
const g = graph("support", SupportState)
.node("agent", async (state, ctx) => {
// First pass halts here; the resume pass returns the human's answer.
const answer = await ctx.interrupt<string>({ question: "Approve purchase?" });
return { messages: [`approved: ${answer}`] };
})
.edge(START, "agent").edge("agent", END)
.compile();
const opts = { threadId: "conv-42", checkpointer: new InMemoryCheckpointer() };
const paused = await run(g, { messages: ["buy"] }, opts); // status: "interrupted"
const done = await resume(g, "yes", opts); // status: "done"
using Ilmek;
/// <summary>Support state — one append-only channel of messages.</summary>
sealed class SupportState
{
[Append] public List<string> Messages { get; set; } = new();
}
var g = Graph.Create<SupportState>("support")
.Node("agent", async (state, ctx) =>
{
// First pass halts here; the resume pass returns the human's answer.
var answer = await ctx.InterruptAsync<string>(new { question = "Approve purchase?" });
return Update.For<SupportState>().Append(s => s.Messages, $"approved: {answer}");
})
.Edge(Graph.Start, "agent")
.Edge("agent", Graph.End)
.Compile();
var opts = new RunOptions { ThreadId = "conv-42", Checkpointer = new InMemoryCheckpointer() };
var buy = Update.For<SupportState>().Append(s => s.Messages, "buy");
var paused = await IlmekRuntime.RunAsync(g, buy, opts); // Status: Interrupted
var done = await IlmekRuntime.ResumeAsync(g, "yes", opts); // Status: Done
Everything falls out of the journal
None of the following is special-cased — each is a direct consequence of the journal:
- Multiple pauses per node — each
interrupthas its own key; each resolves once; replay fast-forwards through the already-answered ones. No index-matching. - Pauses inside loops — work, given stable keys.
- Concurrent interrupts — two tasks in one superstep may both halt pending; the run emits both and resumes both when both are answered.
- Effects before a pause never re-run.
- An interrupt is a first-class control signal, never an exception smuggled through an error channel. Consumers never inspect error strings to discover a pause.
id vs key
This is the one wrinkle worth knowing up front.
A journal key is unique within its task and nowhere else. Two nodes that each
call a bare interrupt in the same superstep therefore both journal
interrupt#0 — each task counts occurrences on its own. So a pending interrupt
carries two handles:
| Field | Scope | Used for |
|---|---|---|
key | the task ("interrupt#0") | addressing the journal entry |
id | the thread ("<node>:<key>") | addressing the pause from outside |
Resume answers must be keyed by id. Keying by key looks fine until the
first concurrent pause, then silently drops an answer and hands both nodes the
same one — a data-corruption bug with no error.
Two resume forms
Which form applies is decided by the number of pending interrupts, never by the answer's own type:
- bare answer — legal only when exactly one interrupt is pending. Because the
count decides, an object answer (
{ approved: true }) can never be mistaken for a key map. - keyed answers — a map of
id => answer. Works for any count, so a UI that renders every open pause needs no special case for "exactly one".
Auto-suffixing makes the bare form safe even for several pauses in one node:
- TypeScript
- .NET (C#)
import { graph, channel, START, END, run, resume, InMemoryCheckpointer } from "@ilmek/core";
import type { ChannelMap, StateOf } from "@ilmek/core";
/** Danger state — an append-only log of what was confirmed. */
const DangerState = {
log: channel.append<string>(),
} satisfies ChannelMap;
type DangerState = StateOf<typeof DangerState>;
const g = graph("danger", DangerState)
.node("confirm", async (state, ctx) => {
const ok = await ctx.interrupt({ question: "Delete production?" }); // interrupt#0
const sure = await ctx.interrupt({ question: "Really sure?" }); // interrupt#1
return { log: [`deleted: ${ok} / ${sure}`] };
})
.edge(START, "confirm").edge("confirm", END)
.compile();
const opts = { threadId: "conv-42", checkpointer: new InMemoryCheckpointer() };
// One interrupt is pending at a time, so the bare-answer form stays safe
// even though the node pauses twice.
await run(g, {}, opts); // halts on interrupt#0
await resume(g, "yes", opts); // answers #0, halts on interrupt#1
const done = await resume(g, "yes", opts); // answers #1 → status: "done"
using Ilmek;
/// <summary>Danger state — an append-only log of what was confirmed.</summary>
sealed class DangerState
{
[Append] public List<string> Log { get; set; } = new();
}
var g = Graph.Create<DangerState>("danger")
.Node("confirm", async (state, ctx) =>
{
var ok = await ctx.InterruptAsync<string>(new { question = "Delete production?" }); // interrupt#0
var sure = await ctx.InterruptAsync<string>(new { question = "Really sure?" }); // interrupt#1
return Update.For<DangerState>().Append(s => s.Log, $"deleted: {ok} / {sure}");
})
.Edge(Graph.Start, "confirm")
.Edge("confirm", Graph.End)
.Compile();
var opts = new RunOptions { ThreadId = "conv-42", Checkpointer = new InMemoryCheckpointer() };
// One interrupt is pending at a time, so the bare-answer form stays safe
// even though the node pauses twice.
await IlmekRuntime.RunAsync(g, new DangerState(), opts); // halts on interrupt#0
await IlmekRuntime.ResumeAsync(g, "yes", opts); // answers #0, halts on interrupt#1
var done = await IlmekRuntime.ResumeAsync(g, "yes", opts); // answers #1 → Status: Done
Run the interactive demo to feel it:
- TypeScript
- .NET (C#)
cd ts && node examples/checkout.ts # 💳 create_order → ⏸ paused → 💰 charge
cd dotnet && dotnet run --project examples/Ilmek.Examples # 💳 create_order → ⏸ paused → 💰 charge