<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Tamir Dresher on Medium]]></title>
        <description><![CDATA[Stories by Tamir Dresher on Medium]]></description>
        <link>https://medium.com/@tamir_dresher?source=rss-21fc7aa0950d------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*yAQOoAqKJeTQfqYsHDcXUA.png</url>
            <title>Stories by Tamir Dresher on Medium</title>
            <link>https://medium.com/@tamir_dresher?source=rss-21fc7aa0950d------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 13:37:06 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@tamir_dresher/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Copilot remembered the bookings. Why couldn’t it resume the workflow?]]></title>
            <link>https://medium.com/@tamir_dresher/copilot-remembered-the-bookings-why-couldnt-it-resume-the-workflow-4d35325de705?source=rss-21fc7aa0950d------2</link>
            <guid isPermaLink="false">https://medium.com/p/4d35325de705</guid>
            <category><![CDATA[microsoft-azure]]></category>
            <category><![CDATA[build-ai-agents]]></category>
            <category><![CDATA[workflow-orchestration]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[github-copilot]]></category>
            <dc:creator><![CDATA[Tamir Dresher]]></dc:creator>
            <pubDate>Thu, 15 Oct 2026 21:00:51 GMT</pubDate>
            <atom:updated>2026-10-18T05:08:00.709Z</atom:updated>
            <content:encoded><![CDATA[<p>It started with a simple piece of curiosity: <strong>when I ask GitHub Copilot to create a dynamic workflow, how does it actually work behind the scenes?</strong></p><p>You type a plain-English sentence — <em>“build me a workflow that books a flight, a hotel, and a rental car”</em> — and a moment later, Copilot has registered an executable multi-agent workflow in your session. Very cute. Very magical. Very “look, Mom, the agents planned my vacation.”</p><p>Except I don’t like magic in my runtimes. I wanted to know what was actually happening under the hood between my prompt and that running workflow. How does the model even know the workflow API? Is it baked into the model weights? Stuffed into a giant hidden system prompt? Loaded from a secret skill? Where does the generated code live on disk, and what actually executes it?</p><p>So I did what I always do when a feature feels like a black box: I started pulling threads. I traced the tool schemas through the CLI binary, inspected the session logs, and opened Procmon to watch which files the process actually touched on disk.</p><p>And of course, once I saw how the runtime builds and journals a workflow, the distributed-systems part of my brain (if you read my <a href="https://www.tamirdresher.com/blog/2026/04/07/durable-task-scheduler">earlier post on Azure Durable Task Scheduler</a>, you already know where this is going) couldn’t resist asking the next question: <strong>what happens if I kill the process right after the hotel is booked, before the rental car agent finishes?</strong></p><p>Stick with me. What started as a peek behind the curtain turned into a great lesson in the difference between <strong>having a log of what happened</strong> and <strong>having a durable workflow you can actually resume</strong>.</p><h3>Peeking Behind the Curtain: How Copilot Learns to Write a Workflow</h3><p>When you ask Copilot to create a dynamic workflow, there’s no magic Markdown compiler turning your English into a state machine. It’s a very practical, three-step handshake between the native CLI runtime and the bundled Copilot SDK.</p><p>First, the native runtime exposes the workflow tools (dynamic_workflows_manage and run_dynamic_workflow) to the model through standard tool descriptions and schemas. If you trace the strings in the CLI (1.0.94-3 in my initial trace), they lead straight through the JavaScript bridge into the bundled prebuilds\win32-x64\runtime.node.</p><p>And right inside the tool description for dynamic_workflows_manage, the runtime leaves the model a breadcrumb: <em>call </em><em>operation: &quot;guide&quot; before authoring any workflow code.</em></p><p>So that’s step one. The model calls dynamic_workflows_manage({ operation: &quot;guide&quot; }), and the runtime hands back the local paths to the bundled SDK documentation:</p><pre>%LOCALAPPDATA%\Programs\GitHub Copilot\copilot-sdk\docs\workflows.md<br>%LOCALAPPDATA%\Programs\GitHub Copilot\copilot-sdk\index.d.ts</pre><p>(Depending on how you installed Copilot CLI, that path might live under %LOCALAPPDATA%\copilot\pkg\win32-x64\&lt;version&gt;\copilot-sdk\docs\workflows.md instead — which is why the guide operation returns the exact path for your active install rather than hardcoding it.)</p><p>Step two is refreshingly low-tech: the model simply calls view on that workflows.md path and reads the manual. Literally. The <a href="https://github.com/github/copilot-sdk/blob/6be623b01b194a61466385e3f708639ec98ce8ac/nodejs/docs/workflows.md">SDK workflow guide</a> enters the context window as a normal tool result, teaching the model the exact shape of defineWorkflow, ctx.step, ctx.agent, and ctx.parallel.</p><p>Step three is turning that knowledge into a runnable extension. In the author flow, the model calls dynamic_workflows_manage({ operation: &quot;author&quot;, meta, run }), passing the workflow metadata and the JavaScript run function body. The host validates it, wraps it in a small ES module scaffold under the session’s extensions\&lt;name&gt;\extension.mjs folder, drops an .authored-workflow marker next to it, and reloads the extension into the session:</p><pre>import { defineWorkflow, joinSession } from &quot;@github/copilot-sdk/extension&quot;;</pre><pre>const workflow = defineWorkflow({<br>    /* generated meta and run omitted */<br>});</pre><pre>await joinSession({ workflows: [workflow] });</pre><p>Notice the clean division of labor: the model writes the meta declaration and the run body; the host writes the import and joinSession boilerplate; and @github/copilot-sdk/extension resolves to the bundled SDK inside the extension host. Creating and registering the workflow doesn’t run it — execution is a separate step via run_dynamic_workflow.</p><h3>Watching It Happen Live in the CLI</h3><p>Reading traces is one thing; watching it happen live is much more fun. I spun up a fresh Copilot CLI session (running 1.0.99-2 through cli-tunnel) and asked it to build a Seattle trip-planning workflow — register only, don’t execute yet.</p><figure><img alt="Actual live Copilot CLI input showing the Seattle workflow creation request before submission, with private fields masked." src="https://cdn-images-1.medium.com/max/560/1*DZdoZgUttoCJCk1Et06VbA.png" /><figcaption>Asking Copilot CLI to create and register the Seattle trip-planning workflow.</figcaption></figure><p>Right on cue, the first thing Copilot did was ask permission to read the bundled workflows.md guide from disk:</p><figure><img alt="Actual Copilot CLI permission prompt for bundled workflows.md, with private paths masked." src="https://cdn-images-1.medium.com/max/696/1*UgkN6CxUOwJARpyh4Sg8Pw.png" /><figcaption>Copilot CLI asking permission to read the bundled `copilot-sdk/docs/workflows.md` guide before writing a single line of workflow code.</figcaption></figure><p>In this newer CLI version (1.0.99-2), instead of the single-shot author call, Copilot used the extension lifecycle directly — <strong>guide → scaffold → edit → reload → inspect</strong> — and registered seattle-trip-planning with five ctx.step operations (three parallel simulated bookings, a review step, and a guarded confirmation) without executing it:</p><figure><img alt="Actual Copilot CLI registration response for seattle-trip-planning, explicitly not run, with private identifiers masked." src="https://cdn-images-1.medium.com/max/1024/1*8IKry6hpXDknI1KcFElwuA.png" /><figcaption>The workflow is scaffolded, reloaded, and verified in the registry — ready to run, with zero bookings made yet.</figcaption></figure><p>Because I like to verify what happened behind the curtain, I checked the creation turn from two angles. First, I queried the session’s recorded tool execution log to see the exact sequence of tool calls (guide returning the path → view reading workflows.md → scaffolding the extension → reading and patching extension.mjs):</p><figure><img alt="Genuine live Copilot CLI display of a later read-only audit&#39;s verbatim stdout for selected creation tool records, with private command-path details masked." src="https://cdn-images-1.medium.com/max/479/1*YfgEiTkAahljB1C3LDxGAA.png" /><figcaption>Inspecting the session&#39;s tool log: `guide` returns the SDK doc path, `view` reads `workflows.md`, and the extension scaffold gets patched in place.</figcaption></figure><p>And second, because nothing beats OS-level ground truth, I opened Sysinternals Process Monitor (Procmon) and watched the process touch copilot-sdk\docs\workflows.md on disk:</p><figure><img alt="Actual Procmon window showing later installed workflows.md guide accesses, with private path segments masked and limited visible columns." src="https://cdn-images-1.medium.com/max/603/1*r6E6rrEi6kjJIXZFFRSNsg.png" /><figcaption>Procmon catching the file system accesses to the installed `copilot-sdk\docs\workflows.md` guide on disk.</figcaption></figure><p>When you run that workflow on the happy path — say, a simulated trip from SFO to Seattle for November 10–14, 2026 — it flies through in one shot and prints a clean booking table:</p><figure><img alt="Actual Copilot CLI completion summary for three valid simulated Seattle trip bookings, with private identifiers masked." src="https://cdn-images-1.medium.com/max/479/1*DPGehRzWR6sfw0VTA7AcUw.png" /><figcaption>The happy path: all three simulated bookings complete and the trip confirms cleanly on the first attempt.</figcaption></figure><h3>What Copilot Actually Writes When You Don’t Coach It</h3><p>Before we break things, it’s worth looking at the code Copilot generated when I gave it an open-ended prompt in a fresh desktop session:</p><blockquote><em>Create a Copilot dynamic workflow that plans a trip: given a destination, book a flight, a hotel and a rental car, review booking results, then confirm. Create/register only, do not execute or make reservations.</em></blockquote><p>I deliberately didn’t mention Durable Task Scheduler, idempotency keys, or replay boundaries. I wanted to see what “natural” Copilot workflow code looks like out of the box.</p><p>To its credit, it didn’t write a naive “call three things and print success” script. It set up <strong>three parallel booking agents</strong>, followed by <strong>a fourth review agent</strong>, followed by <strong>a guarded confirmation step</strong>:</p><pre>const services = [&quot;flight&quot;, &quot;hotel&quot;, &quot;rental car&quot;];<br>const results = await ctx.parallel(services.map((service) =&gt; () =&gt; ctx.agent(<br>    `Book the ${service} for a trip to ${JSON.stringify(tripDestination)}. ` +<br>    &quot;Use available booking tools, actual service responses, and only travel details, constraints, and authorization already supplied by the user. &quot; +<br>    &quot;If essential details, authorization, or booking tools are missing, return needs_input and explain what is needed. &quot; +<br>    &quot;Return booked only after the travel service confirms the reservation, with its actual confirmation number. &quot; +<br>    &quot;If the booking fails, return failed with the reason. Never invent a reservation or confirmation number.&quot;,<br>    { label: `Book ${service}`, schema: bookingSchema },<br>)));</pre><p>It even added structured schema checks (booked, needs_input, failed), verified that every booking returned a non-empty confirmation number, and had the review agent validate consistency before allowing the final ctx.step(&quot;confirm-trip&quot;, ...) to confirm the itinerary:</p><pre>if (!allBooked || !review.approved || review.issues.length &gt; 0) {<br>    return { status: &quot;not_confirmed&quot;, destination: tripDestination, bookings, review };<br>}</pre><pre>return await ctx.step(&quot;confirm-trip&quot;, () =&gt; ({<br>    status: &quot;confirmed&quot;,<br>    destination: tripDestination,<br>    bookings,<br>    review,<br>}));</pre><p>As application logic goes, that’s genuinely thoughtful code.</p><p>As a <strong>durable distributed workflow</strong>, though, look closely at what’s missing. There are no provider idempotency keys. There’s no external orchestration state mapping a logical trip to a resumable run. And wrapping the final return object in ctx.step(&quot;confirm-trip&quot;, ...) checkpoints the final summary — it doesn’t magically wrap the external tool calls happening <em>inside</em> those three parallel ctx.agent(...) calls in durable, idempotent boundaries.</p><p>Which brings us to the fun part: pulling the plug.</p><h3>Pulling the Plug Between “Booked” and “Finished”</h3><p>Let’s run the crash test. (And before anyone worries about my corporate Amex: all bookings in these tests hit local fake booking providers. The Copilot workflows and agents were real; the airlines were not.)</p><p>I ran that exact generated trip workflow on the default local executor (1.0.95-3) and engineered a very specific crash point:</p><ol><li><strong>Flight agent</strong> calls the flight tool, gets a simulated reservation, and finishes (flight = 1).</li><li><strong>Hotel agent</strong> calls the hotel tool, gets a simulated reservation, and finishes (hotel = 1).</li><li><strong>Car agent</strong> reaches the rental car tool — and I hold the request right <em>before</em> it creates the reservation (car = 0).</li><li>While the workflow is mid-flight, I kill the CLI, extension host, and provider processes.</li></ol><p>Then I reopened the exact same Copilot session.</p><p>Here’s the part that surprised me at first. This wasn’t a “the process died and wiped its memory” failure. The local journal <strong>had</strong> saved the completed flight and hotel records. The fake provider receipts were sitting right there on disk. Copilot <em>remembered</em> the bookings.</p><p>So I asked it to resume the interrupted run. And the runtime came back with a flat refusal:</p><blockquote><em>was interrupted before it settled durably and cannot be resumed</em></blockquote><p>The run was marked error (reason: interrupted, canResume: false). Even worse, the old rental car agent row was still stuck reporting running in storage after the process had died, leaving a phantom live-agent count of 1.</p><h3>How Copilot Actually Maintains Its Native Ledger (and Why It Still Wasn’t Enough)</h3><p>To understand why Copilot remembered the bookings but still refused to continue, I looked at how the SDK and native CLI actually maintain workflow state under the hood (anchored in <a href="https://github.com/github/copilot-sdk/blob/6be623b01b194a61466385e3f708639ec98ce8ac/nodejs/src/session.ts#L1696">nodejs/src/session.ts</a> and src\runtime\src\workflows\store.rs in the native runtime).</p><p>Copilot’s default local workflow runner is <strong>not</strong> stateless. When a workflow starts, the native runtime mints a runId and an active executionToken for that attempt, and every method on ctx talks back to the native session store over JSON-RPC:</p><ul><li><strong>ctx.step(key, producer)</strong> is a read-through, write-through memoization cache backed by the native ledger (session.workflow.journal.get and session.workflow.journal.put).</li><li><strong>ctx.agent(prompt, options)</strong> calls session.workflow.agent, which spawns the sub-agent and records a full lifecycle row (agentId, phaseId, status, resolvedModel, startedAt, completedAt, activeMs, plus consumed sub-agent and token accounting on the run envelope).</li><li><strong>ctx.log and </strong><strong>ctx.phase</strong> batch ordered progress lines through WorkflowProgressBuffer and flush them via session.workflow.log.</li></ul><p>Look at the ctx.step implementation in nodejs/src/session.ts — it’s super clean:</p><pre>const cached = await awaitWorkflowOperation(<br>    () =&gt;<br>        self.rpc.workflow.journal.get({<br>            runId: params.runId,<br>            executionToken: params.executionToken,<br>            key,<br>        }),<br>    controller.signal<br>);<br>if (cached.hit) {<br>    return cached.resultJson;<br>}</pre><pre>const result = await producer();<br>await awaitWorkflowOperation(<br>    () =&gt;<br>        self.rpc.workflow.journal.put({<br>            runId: params.runId,<br>            executionToken: params.executionToken,<br>            key,<br>            resultJson: result,<br>        }),<br>    controller.signal<br>);<br>return result;</pre><p>So why couldn’t it resume if journal.put and workflow.agent had already written the completed outputs to disk?</p><p>Because in the native store, <strong>having journal entries on disk is not what makes a run resumable.</strong> A run is only marked canResume: true when the attempt settles cleanly at a controlled checkpoint (for example, pauseAtCheckpoint). When you kill the process mid-flight while an executionToken is still active, the runtime sees an ungracefully severed attempt. On rejoin, it marks the run envelope as status: &quot;error&quot;, reason: &quot;interrupted&quot;, and canResume: false — and any call to session.workflow.resume (resumeFromRunId) gets rejected before it ever touches the step journal.</p><p>To verify that directly against journal.get / journal.put, I reproduced the exact same crash boundary in the 1.0.99-2 CLI with a deterministic step-only version (seattle-trip-interruption-demo). All three parallel bookings and the review step finished and were written to the native step journal via session.workflow.journal.put, and the workflow paused at a file gate right before final confirmation (WAITING_FOR_OPERATOR_RELEASE):</p><figure><img alt="Actual Copilot CLI running status and operator-release waiting log for the separate controlled-interruption demo, with private identifiers masked." src="https://cdn-images-1.medium.com/max/479/1*Y1zcs_AdA6dmv-JzvqZTZQ.png" /><figcaption>Pausing the workflow right before final confirmation — three bookings and the review step are already completed and saved in the local journal.</figcaption></figure><p>I killed the CLI and its workers, reopened the same conversation, and called run_dynamic_workflow with resumeFromRunId for that run. Same result:</p><figure><img alt="Actual Copilot CLI same-run resume refusal in the reopened controlled-demo conversation, with private identifiers masked." src="https://cdn-images-1.medium.com/max/479/1*YKEgXA2grYwl3OMWW54WHA.png" /><figcaption>Reopening the session and attempting `resumeFromRunId`: the journal still has all four completed step results, but the runtime refuses to resume the interrupted run.</figcaption></figure><p>All four completed step outputs were still sitting in the database byte-for-byte intact. The ledger remembered the <strong>data</strong>, but the crashed attempt lost <strong>execution authority</strong>.</p><p>That’s the distinction that’s so easy to miss when you only test the happy path:</p><p><strong>Remembering completed work and being able to resume a workflow are two completely different capabilities.</strong></p><p>A ledger tells you what happened before the crash. A durable orchestrator knows how to <em>replay</em> that history, re-establish execution authority, and pick up at the exact next step without re-running completed work.</p><h3>Wiring in Durable Task Scheduler (and Where the Native Boundary Pushes Back)</h3><p>This is the exact problem space <a href="https://www.tamirdresher.com/blog/2026/04/07/durable-task-scheduler">Azure Durable Task Scheduler (DTS)</a> was built for. So I built an <a href="https://github.com/tamirdresher/copilot-sdk/commit/fc837d41186510bfa5842d8dd7a1c00aa28ac300">experimental fork of </a><a href="https://github.com/tamirdresher/copilot-sdk/commit/fc837d41186510bfa5842d8dd7a1c00aa28ac300">copilot-sdk</a> to see what it takes to plug DTS into joinSession.</p><p>I added an optional <a href="https://github.com/tamirdresher/copilot-sdk/blob/fc837d41186510bfa5842d8dd7a1c00aa28ac300/nodejs/src/workflow-executor.ts">WorkflowExecutor seam</a> so an extension can bring its own durable execution engine while defaulting to normal local behavior if omitted:</p><pre>export interface WorkflowExecutor {<br>    execute(<br>        definition: WorkflowDefinition,<br>        invocation: WorkflowInvocation,<br>        host: WorkflowExecutionHost,<br>        signal: AbortSignal<br>    ): Promise&lt;{ result?: JsonValue }&gt;;<br>}</pre><p>Under the hood, the architecture splits cleanly into three pieces:</p><ul><li><strong>The client</strong> schedules or attaches to a deterministic DTS orchestration instance.</li><li><strong>The worker</strong> pulls work items and runs the JavaScript workflow code.</li><li><strong>The scheduler (DTS)</strong> persists the orchestration history across process crashes.</li></ul><p>When a worker restarts after a crash, the <a href="https://github.com/tamirdresher/copilot-sdk/blob/fc837d41186510bfa5842d8dd7a1c00aa28ac300/nodejs/src/workflow-replay.ts">replay adapter (</a><a href="https://github.com/tamirdresher/copilot-sdk/blob/fc837d41186510bfa5842d8dd7a1c00aa28ac300/nodejs/src/workflow-replay.ts">workflow-replay.ts)</a> walks the workflow code against the recorded JSON history in DTS. Every Agent, Step, and Progress call becomes a tracked activity. If DTS already has a recorded completion for step 1, replay returns the cached JSON immediately without re-invoking the agent or step, and advances until it hits the first unseen operation.</p><h3>How Recovery Actually Works: The Carrier Run Pattern</h3><p>Here’s the catch, though: WorkflowExecutor lives in the JavaScript SDK host, while resumeFromRunId is gated by the native CLI runtime. Teaching the JavaScript layer about DTS doesn’t magically rewrite the native binary’s rule that an interrupted native run ID is non_resumable.</p><p>So how do you recover an interrupted DTS-backed workflow when the native CLI refuses resumeFromRunId?</p><p>You use a <strong>new native run as a carrier</strong> for the <strong>existing DTS orchestration instance</strong>.</p><p>I tested this end-to-end using the official <a href="https://github.com/tamirdresher/copilot-sdk/blob/fc837d41186510bfa5842d8dd7a1c00aa28ac300/nodejs/samples/review-changed.mjs">changed-file code review workflow (</a><a href="https://github.com/tamirdresher/copilot-sdk/blob/fc837d41186510bfa5842d8dd7a1c00aa28ac300/nodejs/samples/review-changed.mjs">review-changed.mjs)</a> running against a local DTS emulator. The workflow reviews two files in sequence: a cart-total calculation (which has a bug: reduce missing its initial 0 accumulator) and a discount-price function (which accidentally subtracts a percentage instead of multiplying).</p><ol><li>On Run 1, the first review agent finished analyzing the cart fixture (492 bytes of structured review output) and DTS checkpointed the completion.</li><li>Before the second review agent could finish, I killed the process.</li><li>After reopening, instead of asking the native CLI to resume the dead native run ID, I launched a fresh native carrier run configured via <a href="https://github.com/tamirdresher/copilot-sdk/blob/fc837d41186510bfa5842d8dd7a1c00aa28ac300/nodejs/src/workflow-dts.ts">workflow-dts.ts</a> to attach to the <strong>original DTS instance ID</strong> (verifying that the workflow scope, arguments, and code hash matched).</li></ol><p>It worked like a charm.</p><p>The carrier run attached to the existing DTS orchestration, replayed past the first step by reusing the <strong>exact 492-byte cart review from DTS history</strong>, and spawned <strong>only one new agent</strong> to finish the discount-file review. Across the crash and recovery, only two workflow agents ever ran — one per file.</p><p>Is it seamless UI magic yet? Not quite. Because the carrier is a new native run, the CLI UI doesn’t automatically rehydrate the old Review phase progress bars from the dead run, and you still have to manage your logical run mapping and budgets across attempts (plus make sure your external tools carry idempotency keys if a worker dies right after an API call succeeds). Honest engineering trade-offs — but the core replay mechanics are rock solid.</p><h3>Where This Leaves Us</h3><p>I started this whole rabbit hole just wanting to know how Copilot turns a plain-English sentence into a runnable workflow.</p><p>What I love about looking under the hood here is how pragmatic the design actually is. There’s no mystery compiler — just a clean guide → view → extension.mjs loop that teaches the model the SDK on demand and registers normal JavaScript into the session. And the local runtime already does the work of journaling step outputs, agent runs, and progress lines as it goes.</p><p>Where things get interesting is the gap between <strong>keeping a ledger</strong> and <strong>recovering a live workflow</strong>. When a process dies mid-flight, having the previous step results sitting on disk isn’t enough if the runtime can’t safely re-establish execution authority and replay past completed work.</p><p>Plugging a WorkflowExecutor seam into <a href="https://github.com/tamirdresher/copilot-sdk/commit/fc837d41186510bfa5842d8dd7a1c00aa28ac300">copilot-sdk</a> showed just how naturally Azure Durable Task Scheduler fits that gap: you keep the exact same defineWorkflow developer experience, let Copilot author the workflow the way it already wants to, and let</p><p>DTS handle the replay when reality inevitably pulls the plug.</p><p><em>Originally published at </em><a href="https://www.tamirdresher.com/blog/2026/10/16/copilot-workflows-durability"><em>https://www.tamirdresher.com</em></a><em> on October 15, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4d35325de705" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[What LangGraph Actually Builds: Runnables, Channels, Bubble-Up, Agents and Deep Agents]]></title>
            <link>https://medium.com/@tamir_dresher/what-langgraph-actually-builds-runnables-channels-bubble-up-agents-and-deep-agents-3c523f4d6203?source=rss-21fc7aa0950d------2</link>
            <guid isPermaLink="false">https://medium.com/p/3c523f4d6203</guid>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <dc:creator><![CDATA[Tamir Dresher]]></dc:creator>
            <pubDate>Fri, 09 Oct 2026 21:00:40 GMT</pubDate>
            <atom:updated>2026-10-11T17:55:39.329Z</atom:updated>
            <content:encoded><![CDATA[<p>I spent the weekend digging through LangGraph’s source code, trying to understand what actually happens beneath the abstractions. These are my notes, the experiments I ran, and the mental model I ended up with.</p><p>The thing that surprised me most is how <em>small</em> the core idea is. A graph, a ReAct agent and a Deep Agent are not three different machines. They are one machine (Pregel) fed three different amounts of configuration.</p><p>Everything below was run offline: no network, no live model, a scripted fake chat model. Versions are pinned to <strong>langgraph 1.2.12, langchain-core 1.6.6, langchain 1.4.3, deepagents 0.7.20</strong>; internals move between releases, so treat the class names as a snapshot. The code is in the <a href="https://github.com/tamirdresher/langgraph-internals-samples">samples repo</a>: <a href="https://github.com/tamirdresher/langgraph-internals-samples/blob/main/langgraph_primitives_demo.py">langgraph_primitives_demo.py</a> produced every printed output in sections 1–6, and <a href="https://github.com/tamirdresher/langgraph-internals-samples/blob/main/structured_cases.py">structured_cases.py</a> covers section 5c.</p><h3>Side notes before we start: Python pieces and C# equivalents</h3><p>If you come from C#, a few Python/LangGraph words show up immediately. They’re plain Python or plain LangGraph.</p><p>You’ll see What it is Closest C# idea TypedDict A dictionary whose keys and value types are declared for type checkers; at run time it&#39;s an ordinary dict. A DTO / record, except it stays a dictionary. Annotated[list, operator.add] A type with attached metadata: &quot;this is a list, <em>and</em> here&#39;s extra info.&quot; Python itself ignores the extra part; LangGraph reads it. A type plus an attribute, e.g. [Reducer(Add)] List&lt;string&gt; visited. operator.add A plain function that does a + b (for lists, concatenation). A method group such as (a, b) =&gt; a + b, passed as a Func&lt;T,T,T&gt;. lambda x: x + 1 An anonymous function. That&#39;s what I meant by &quot;unnamed&quot; before: not a keyword of this project, just Python&#39;s lambda. A C# lambda x =&gt; x + 1; a Func&lt;int,int&gt; delegate. a function passed as a value Functions are first-class objects, so add_node(&quot;a&quot;, a) just hands over the function. Passing a delegate or method group. invoke, stream Methods on a runnable. Execute / IAsyncEnumerable&lt;T&gt; on an interface.</p><p>Two LangGraph words are worth fixing in your head now.</p><p><strong>State</strong> is the dictionary that flows through the graph. Every node receives it and returns a partial update to it, not a whole new state. If you know C#, think of an immutable record plus with { ... } expressions, applied by the engine for you.</p><p><strong>Channel</strong> is one named slot of that state, and it owns the rule for how new values are merged in. A state key total becomes a channel called total. The rule matters when several nodes write in the same round: should the new value replace the old one, or be combined with it? In C# terms a channel is roughly a field paired with its own merge function, like Func&lt;T, T, T&gt; merge.</p><p>The name is misleading if you know C#. It is <strong>not</strong> System.Threading.Channels: nothing is queued for a consumer to await, and nothing blocks. It is <strong>not</strong> Rx&#39;s IObservable: nothing pushes values to subscribers. It is not a TPL Dataflow block either, though that is the closest family, because &quot;a node runs when something it depends on was updated&quot; is the same idea as dataflow triggering. The nearest literal picture is a <strong>versioned cell in a shared state object</strong>: a field whose Update(IEnumerable&lt;T&gt; writes) method decides how writes combine, plus a version number that goes up when it changes. Nodes are woken by comparing those versions, not by receiving messages. (Two exceptions behave a bit more like queues: Topic, used for Send, collects several values; EphemeralValue holds a value for one round only.)</p><p><strong>Reducer</strong> is that merge function: reducer(old, new) -&gt; combined. You attach one with Annotated. If you&#39;ve used LINQ&#39;s Aggregate, it&#39;s the same shape: fold each new value into a running one. operator.add as a reducer on a list means &quot;append the new items to the old items&quot;.</p><p>With those in hand, here is the smallest interesting graph.</p><h3>1. The only contract: Runnable</h3><p>A Runnable is anything with invoke, ainvoke, stream, astream, batch and with_config. That&#39;s it. In C# you&#39;d call this an interface that everything implements, an IRunnable&lt;TIn, TOut&gt;. The | operator composes two of them (Python lets a class overload |, like a C# operator |):</p><pre>chain = RunnableLambda(lambda x: x + 1) | RunnableLambda(lambda x: x * 10)<br>type(chain).__name__   # RunnableSequence<br>chain.invoke(1)        # 20</pre><p>Why this matters: a compiled LangGraph is <em>also</em> a Runnable. Its MRO starts CompiledStateGraph → Pregel → PregelProtocol. (MRO is the method resolution order, Python&#39;s inheritance chain: CompiledStateGraph : Pregel : PregelProtocol.) So a whole graph can sit inside a chain, or inside a node of another graph. That is how subagents work later.</p><h3>2. What compile() produces</h3><p>Take the smallest interesting graph: two nodes with a state that has an overwrite field and an append field. A node is just a function that takes the state and returns a partial update:</p><pre>import operator<br>from typing import Annotated, TypedDict<br>from langgraph.graph import StateGraph, START, END</pre><pre>class S(TypedDict):<br>    total: int                              # bare key: one write per superstep<br>    visited: Annotated[list, operator.add]  # reducer: appends</pre><pre>def a(s):                               # node &quot;a&quot;: doubles n, logs &quot;a&quot;<br>    return {&quot;total&quot;: s[&quot;total&quot;] * 2, &quot;visited&quot;: [&quot;a&quot;]}</pre><pre>def b(s):                               # node &quot;b&quot;: adds 1 to n, logs &quot;b&quot;<br>    return {&quot;total&quot;: s[&quot;total&quot;] + 1, &quot;visited&quot;: [&quot;b&quot;]}</pre><pre>g = StateGraph(S)<br>g.add_node(&quot;a&quot;, a)<br>g.add_node(&quot;b&quot;, b)<br>g.add_edge(START, &quot;a&quot;)<br>g.add_edge(&quot;a&quot;, &quot;b&quot;)<br>g.add_edge(&quot;b&quot;, END)<br>app = g.compile()<br>app.invoke({&quot;total&quot;: 10, &quot;visited&quot;: []})        # {&#39;total&#39;: 21, &#39;visited&#39;: [&#39;a&#39;, &#39;b&#39;]}</pre><p>StateGraph is only a builder (like a C# ServiceCollection before BuildServiceProvider()). compile() lowers it into primitives. Here is what the demo prints for the compiled object:</p><p>Those lines are not part of the graph code above. They come from a small inspection helper I wrote (it’s show() in the <a href="https://github.com/tamirdresher/langgraph-internals-samples/blob/main/langgraph_primitives_demo.py">demo script</a>). It reads the public attributes of the compiled object, app.channels and app.nodes:</p><pre># `app` is the compiled graph from above (a Pregel object)<br>print(&quot;channels:&quot;, {name: type(ch).__name__ for name, ch in app.channels.items()<br>                    if not name.startswith(&quot;branch:&quot;)})    # hide the per-edge channels for brevity<br>for name, node in app.nodes.items():<br>    if name == &quot;__start__&quot;:<br>        continue                                            # internal input node, skipped for brevity<br>    print(f&quot;node {name!r}: triggers={node.triggers} bound={type(node.bound).__name__}&quot;<br>          f&quot; writers={len(node.writers)}&quot;)</pre><p>which prints:</p><pre>channels: {&#39;total&#39;: &#39;LastValue&#39;, &#39;visited&#39;: &#39;BinaryOperatorAggregate&#39;,<br>           &#39;__start__&#39;: &#39;EphemeralValue&#39;, &#39;__pregel_tasks&#39;: &#39;Topic&#39;}<br>node &#39;a&#39;: triggers=[&#39;branch:to:a&#39;] bound=RunnableCallable writers=2<br>node &#39;b&#39;: triggers=[&#39;branch:to:b&#39;] bound=RunnableCallable writers=1</pre><p>The lines starting with node describe PregelNode objects, so here is what one is. This is the class from langgraph/pregel/_read.py, trimmed to the fields that matter here (I left out error-handler and subgraph fields, and the docstrings are mine, condensed from the source&#39;s):</p><pre>class PregelNode:<br>    # A container, not a Runnable. The engine uses it to build a runnable task each time the node fires.</pre><pre>    channels: str | list[str]      # which channels to READ as input. For node &quot;a&quot;: [&#39;total&#39;, &#39;visited&#39;],<br>                                   # so your function receives a dict with those two keys (the `s` in `def a(s)`)<br>    triggers: list[str]            # which channels WAKE the node: if any is written, the node runs next step.<br>                                   # For node &quot;a&quot;: [&#39;branch:to:a&#39;]<br>    bound: Runnable                # the actual logic: a Runnable wrapping your function, called with the input<br>    writers: list[Runnable]        # run AFTER `bound`; each takes its output and writes it to channels<br>                                   # (one stores your return value in state, one wakes the next node)<br>    mapper: Callable | None        # optional transform of the input before `bound` (None for node &quot;a&quot;)</pre><pre>    retry_policy: Sequence[RetryPolicy] | None   # what happens if the node raises (None here)<br>    cache_policy: CachePolicy | None             # reuse results for identical input (None here)<br>    timeout: TimeoutPolicy | None                # per-invocation limit (async nodes only)<br>    tags: Sequence[str] | None                   # tracing labels<br>    metadata: Mapping[str, Any] | None           # tracing metadata</pre><p>Note the split between channels (what it reads) and triggers (what wakes it). They are separate on purpose: a node can read many channels but be woken by just one. In C# terms a PregelNode is a record of &quot;my inputs, my wake-up conditions, my handler, my post-processing&quot;, much like a message handler registration with a filter, except the messages are channel updates.</p><p>How to read the printout, line by line:</p><ul><li><strong>&#39;total&#39;: &#39;LastValue&#39;</strong>: the total field became a channel of type LastValue. It holds one value, and a write replaces it. That is what the plain total: int in S meant.</li><li><strong>&#39;visited&#39;: &#39;BinaryOperatorAggregate&#39;</strong>: the visited field became a channel that combines old and new with your function, here operator.add. That is what Annotated[list, operator.add] meant, and why the list grows.</li><li><strong>&#39;__start__&#39;: &#39;EphemeralValue&#39;</strong>: an internal channel that carries your invoke(...) input into the graph. EphemeralValue means it only lives for one round.</li><li><strong>&#39;__pregel_tasks&#39;: &#39;Topic&#39;</strong>: an internal queue-like channel. It is where Send packets go (section 5c); our graph doesn&#39;t use it, but every graph has it.</li><li><strong>node &#39;a&#39;: triggers=[&#39;branch:to:a&#39;]</strong>: node a runs when the channel branch:to:a is updated. That channel is what the edge START → a turned into, so there is no edge object, only &quot;wake a when this channel changes&quot;.</li><li><strong>bound=RunnableCallable</strong>: the thing that actually runs. It is a Runnable wrapper around your plain function a.</li><li><strong>writers=2 vs </strong><strong>writers=1</strong>: after a runs, two things write: one stores its returned update into total/visited, and one writes to branch:to:b to wake node b. b is last, so it only has the first writer; its edge goes to END, which wakes nothing.</li></ul><p>Note the channel list has no branch:to:a or branch:to:b, because my filter hid them. They exist, one per edge, and the nodes&#39; triggers point at them.</p><p>Here is the same transformation as a picture. The first diagram is what you drew with add_edge; the second is what compile() actually builds (I added the __start__ input channel and the two writers per node to the picture, as the printout showed them):</p><p><strong>What you wrote (builder):</strong></p><pre>flowchart LR<br>   S([START]) --&gt; a --&gt; b --&gt; E([END])</pre><p><strong>What </strong><strong>compile() built (runtime):</strong></p><pre>flowchart TD<br>   I[&quot;__start__ channel&lt;br/&gt;(your input)&quot;] --&gt;|wakes| CA[&quot;channel branch:to:a&quot;]<br>   CA --&gt;|wakes| NA[&quot;node a&lt;br/&gt;reads: total, visited&lt;br/&gt;bound = a(s)&lt;br/&gt;writer1 → total, visited&lt;br/&gt;writer2 → channel branch:to:b&quot;]<br>   NA --&gt;|wakes| NB[&quot;node b&lt;br/&gt;reads: total, visited&lt;br/&gt;bound = b(s)&lt;br/&gt;writer1 → total, visited&lt;br/&gt;(no writer2: its edge goes to END)&quot;]</pre><p>The arrows between nodes are gone in the second diagram. What remains are boxes (nodes) and named mailboxes (channels): “a” does not know “b” exists, it just drops a message in branch:to:b.</p><p>So the mental model is:</p><ul><li><strong>Channels</strong> hold state (see the side notes above). Each state key becomes a channel whose <em>type</em> is its merge rule: LastValue keeps the latest value across steps but accepts <strong>at most one write per superstep</strong> (two parallel writers raise InvalidUpdateError, shown in 5c); BinaryOperatorAggregate applies your reducer (operator.add), which is why visited appends instead of replacing. Extra channels carry control flow: __start__ holds the input, and an edge a → b becomes a channel named branch:to:b. The double underscores are only a naming convention for internal channels (Python&#39;s __name__ style, not a keyword).</li><li><strong>Nodes</strong> are PregelNodes. Each has triggers (which channels wake it), channels (which it reads), bound (the actual Runnable, here a wrapper around your function) and writers (what it writes after running).</li><li><strong>Edges disappear.</strong> There is no edge object at run time. a → b is &quot;a&#39;s writer writes to branch:to:b, and b triggers on branch:to:b&quot;. get_graph() reconstructs the pretty edge list for drawing, but the engine never walks it.</li></ul><p>Conditional edges and Send use the same mechanism: a router Runnable runs as an extra writer and writes to whichever branch:to:X channels it chooses (or sends a task with its own input for map-reduce).</p><p>To see it, add a router to the same graph: a now picks b or c with g.add_conditional_edges(&quot;a&quot;, route, [&quot;b&quot;, &quot;c&quot;]), where route(s) returns &quot;b&quot; if s[&quot;total&quot;] &gt; 5 else &quot;c&quot;. As code (it reuses S, a and b from above, and adds node c and the router):</p><pre>def c(s):                                # node &quot;c&quot;: the alternative path, resets total<br>    return {&quot;total&quot;: 0, &quot;visited&quot;: [&quot;c&quot;]}</pre><pre>def route(s):                            # the router: returns the NAME of the next node<br>    return &quot;b&quot; if s[&quot;total&quot;] &gt; 5 else &quot;c&quot;</pre><pre>g = StateGraph(S)<br>g.add_node(&quot;a&quot;, a)<br>g.add_node(&quot;b&quot;, b)<br>g.add_node(&quot;c&quot;, c)<br>g.add_edge(START, &quot;a&quot;)<br>g.add_conditional_edges(&quot;a&quot;, route, [&quot;b&quot;, &quot;c&quot;])   # replaces add_edge(&quot;a&quot;, &quot;b&quot;)<br>g.add_edge(&quot;b&quot;, END)<br>g.add_edge(&quot;c&quot;, END)<br>app = g.compile()<br>app.invoke({&quot;total&quot;: 10, &quot;visited&quot;: []})   # {&#39;total&#39;: 21, &#39;visited&#39;: [&#39;a&#39;, &#39;b&#39;]}  (a: 20 &gt; 5 -&gt; b)<br>app.invoke({&quot;total&quot;: 1,  &quot;visited&quot;: []})   # {&#39;total&#39;: 0,  &#39;visited&#39;: [&#39;a&#39;, &#39;c&#39;]}  (a: 2 -&gt; c)</pre><p>I compiled and ran this, and inspected it the same way:</p><pre>channels: total=LastValue, visited=BinaryOperatorAggregate, __start__, __pregel_tasks=Topic,<br>          branch:to:a, branch:to:b, branch:to:c   (the last three are EphemeralValue)<br>node a: triggers=[&#39;branch:to:a&#39;]  writers=[state write, _route]<br>node b: triggers=[&#39;branch:to:b&#39;]  writers=[state write]<br>node c: triggers=[&#39;branch:to:c&#39;]  writers=[state write]</pre><p><strong>What you wrote:</strong></p><pre>flowchart LR<br>   S([START]) --&gt; a<br>   a --&gt; b --&gt; E1([END])<br>   a --&gt; c --&gt; E2([END])</pre><p><strong>What </strong><strong>compile() built:</strong></p><pre>flowchart TD<br>   NA[&quot;node a&lt;br/&gt;writer1: state write → total, visited&lt;br/&gt;writer2: _route(s) ← your router function&quot;]<br>   NA --&gt;|&quot;router returns &#39;b&#39;&quot;| CB[&quot;writes branch:to:b&quot;] --&gt;|wakes| NB[node b]<br>   NA --&gt;|&quot;router returns &#39;c&#39;&quot;| CC[&quot;writes branch:to:c&quot;] --&gt;|wakes| NC[node c]</pre><p>The difference from the linear case is only the second writer of a. Instead of a fixed write to branch:to:b, it is _route, which calls your function and writes to whichever channel the answer names. The other channel is never written, so that node simply doesn&#39;t run. With total starting at 10, a makes it 20, the router picks b, and invoke returns {&#39;total&#39;: 21, &#39;visited&#39;: [&#39;a&#39;, &#39;b&#39;]}; c never fires. Start at 1 and the router picks c instead. (_route is the internal writer&#39;s name as I saw it in this version; treat it as an implementation detail.)</p><h3>3. Pregel: the superstep loop</h3><p>Pregel is a model for computing over graphs, described in a 2010 Google paper (<a href="https://research.google/pubs/pregel-a-system-for-large-scale-graph-processing/">“Pregel: A System for Large-Scale Graph Processing”</a>, SIGMOD 2010). It builds on the older “bulk synchronous parallel” idea. Open-source systems such as Apache Giraph, Spark GraphX and Flink Gelly offer the same vertex-centric model. LangGraph borrows the idea to run your graph (it also names its compiled graph class Pregel, and that is why the module is called pregel). In one sentence: work proceeds in rounds, where every ready node runs in parallel on the same snapshot of the state, and only when all of them finish are their results merged and the next round decided. In C# terms, picture a round of Task.WhenAll(...) over the ready nodes, where every task reads the same snapshot and a barrier merges their results before the next round. (LangGraph&#39;s version is an adaptation: Google&#39;s Pregel passes messages between graph vertices, while LangGraph&#39;s &quot;vertices&quot; are your nodes and the messages are channel writes.) If you work in .NET, you may already have met this algorithm: Microsoft Agent Framework&#39;s workflow engine, in both its C# and Python versions, uses what its docs call &quot;a modified Pregel execution model — a Bulk Synchronous Parallel (BSP) approach with superstep-based processing&quot;, so its executors run in the same supersteps. See <a href="https://learn.microsoft.com/en-us/agent-framework/concepts/workflows/builder-and-execution">Workflow Builder &amp; Execution, &quot;Execution Model: Supersteps&quot;</a> for details. In langgraph/pregel/_loop.py each round is:</p><ol><li>tick: check the stop conditions, then prepare_next_tasks finds every node whose trigger channel was updated since it last ran.</li><li>Run those tasks (in parallel if there is more than one). A task reads the channel values from the <em>start</em> of the round and returns writes; nothing it writes is visible to its siblings.</li><li>after_tick: apply_writes merges all the writes into the channels through each channel&#39;s rule, bumps versions, emits stream output, and (with a checkpointer) saves a checkpoint.</li></ol><p>Repeat until no node is triggered. Running the two-node graph with invoke gives {&#39;total&#39;: 21, &#39;visited&#39;: [&#39;a&#39;, &#39;b&#39;]}, and stream_mode=&quot;updates&quot; shows one superstep per node:</p><pre>[{&#39;a&#39;: {&#39;total&#39;: 20, &#39;visited&#39;: [&#39;a&#39;]}}, {&#39;b&#39;: {&#39;total&#39;: 21, &#39;visited&#39;: [&#39;b&#39;]}}]</pre><p>The “write then apply at the barrier” split is the single most important idea. It’s why parallel branches are safe (they can’t see each other), why reducers exist (two branches writing the same key in the same round need an Annotated reducer, otherwise the graph fails), and why a checkpoint between rounds is a consistent snapshot of ordinary channels (the Deep Agents DeltaChannel is the exception, see 5b).</p><h3>Send: when one node must become many tasks</h3><p>A normal edge says “after a, run b&quot;, once, with the shared state as input. Sometimes you don&#39;t know <em>how many</em> tasks you need until runtime: the model asked for three tools, or you have N documents to process. Send(node, arg) covers that. It is a small packet meaning &quot;run this node once, with this private input&quot;, and a router can return a whole list of them:</p><pre>import operator<br>from typing import Annotated, TypedDict<br>from langgraph.graph import START, END, StateGraph<br>from langgraph.types import Send</pre><pre>class S(TypedDict):<br>    items: list<br>    out: Annotated[list, operator.add]       # reducer, so parallel writes are appended</pre><pre>def fan(s):                                  # router: one Send per item<br>    return [Send(&quot;work&quot;, {&quot;x&quot;: i}) for i in s[&quot;items&quot;]]</pre><pre>def work(a):                                 # &#39;a&#39; is the Send&#39;s argument, NOT the graph state<br>    return {&quot;out&quot;: [a[&quot;x&quot;] * 10]}</pre><pre>g = StateGraph(S)<br>g.add_node(&quot;work&quot;, work)<br>g.add_conditional_edges(START, fan, [&quot;work&quot;])<br>g.add_edge(&quot;work&quot;, END)<br>app = g.compile()</pre><pre>print(app.invoke({&quot;items&quot;: [1, 2, 3], &quot;out&quot;: []}))</pre><p>Streaming with stream_mode=&quot;debug&quot; and printing each task event shows what the loop scheduled (I ran this):</p><pre>task work {&#39;x&#39;: 1} step 1<br>task work {&#39;x&#39;: 2} step 1<br>task work {&#39;x&#39;: 3} step 1<br>{&#39;items&#39;: [1, 2, 3], &#39;out&#39;: [10, 20, 30]}</pre><p>One node, three tasks, all in the same superstep, each with its own input. Who does what:</p><ul><li><strong>You (or </strong><strong>create_agent) write the router.</strong> It only <em>returns</em> the Send objects; it doesn&#39;t run anything.</li><li><strong>The Pregel loop owns the tasks.</strong> The router’s return value is written to the internal __pregel_tasks channel (the Topic from section 2). At the next barrier the loop reads that channel and, for each packet, builds a task for the named node with the packet&#39;s argument as its input (internally prepare_push_task_send, as opposed to the ordinary &quot;triggered by a channel&quot; tasks). The runner then executes those tasks together, like any other superstep.</li><li><strong>When:</strong> the router runs at the end of one superstep; the Send tasks run in the very next one. They then write their results through the normal channels, which is why the example needs the operator.add reducer: three tasks wrote out in the same round.</li></ul><p>The C# picture is await Task.WhenAll(items.Select(i =&gt; Work(i))), except the list is decided by a router in the middle of the graph, and each of those calls is a first-class task that gets its own checkpoint writes. This is exactly what create_agent does for parallel tool calls, as the next section shows.</p><p><strong>In one sentence:</strong> the Pregel loop runs your graph in <em>supersteps</em>, where a superstep is one round in which every ready node runs on the same snapshot of state and all their writes are merged at a barrier before the next round starts.</p><h3>4. Example: an AI agent is just a graph</h3><p>Before the harder parts (bubble-up, reducers, durability), here is the payoff of everything so far: an AI agent is expressed as a graph. It sounds like a different species, but create_agent(model, tools=[...]) returns a CompiledStateGraph, the same kind of object as app in section 2. So we can read how it is built, then run it, then look at the graph it produces.</p><h3>4.1 The code of create_agent</h3><p>create_agent is not magic; it is a function that makes a StateGraph using the same builder calls as section 2 and compiles it. Below is the relevant core of langchain/agents/factory.py (langchain 1.4.3), <strong>condensed by me</strong> to the path with no middleware and no structured output; the real file is about 2,000 lines, and bodies marked ... are omitted. The node and router functions are shown in full, simplified from model_node, _make_model_to_tools_edge and _make_tools_to_model_edge:</p><pre>def create_agent(model, tools, ...):<br>    graph = StateGraph(state_schema=AgentState, ...)   # &#39;messages&#39; etc. become channels</pre><pre>    # ---- node 1: &quot;model&quot; -- a plain function node ----<br>    def model_node(state, runtime):<br>        messages = state[&quot;messages&quot;]                    # read the channel<br>        if system_message:<br>            messages = [system_message, *messages]<br>        ai_message = model.bind_tools(tools).invoke(messages)   # call the chat model<br>        return {&quot;messages&quot;: [ai_message]}               # write: append to &#39;messages&#39;<br>    graph.add_node(&quot;model&quot;, RunnableCallable(model_node))</pre><pre>    # ---- node 2: &quot;tools&quot; -- a ToolNode, itself a Runnable ----<br>    tool_node = ToolNode(tools)    # reads the last AIMessage&#39;s tool_calls, runs each tool,<br>    graph.add_node(&quot;tools&quot;, tool_node)                  # and writes one ToolMessage per call</pre><pre>    graph.add_edge(START, &quot;model&quot;)                      # entry point</pre><pre>    # ---- router after &quot;model&quot;: tool calls -&gt; &quot;tools&quot;, otherwise finish ----<br>    def model_to_tools(state):<br>        last_ai = last_ai_message(state[&quot;messages&quot;])<br>        if not last_ai.tool_calls:                      # classic exit condition of an agent loop<br>            return END<br>        pending = [c for c in last_ai.tool_calls if c[&quot;id&quot;] not in answered_ids(state)]<br>        return [Send(&quot;tools&quot;, [call]) for call in pending]   # one task per pending tool call<br>    graph.add_conditional_edges(&quot;model&quot;, model_to_tools, [&quot;tools&quot;, END])</pre><pre>    # ---- router after &quot;tools&quot;: loop back, unless a return_direct tool ran ----<br>    def tools_to_model(state):<br>        if all_executed_tools_are_return_direct(state):<br>            return END<br>        return &quot;model&quot;                                  # let the model read the results<br>    graph.add_conditional_edges(&quot;tools&quot;, tools_to_model, [&quot;model&quot;])</pre><pre>    return graph.compile(checkpointer=checkpointer, ...)   # same compile() as section 2</pre><p>Three things are worth noticing, because they connect back to what we already saw:</p><ul><li><strong>Nodes are ordinary functions of state.</strong> model_node reads the messages channel and returns a partial update that the reducer appends. That is exactly what node a did in section 2. ToolNode is just another Runnable.</li><li><strong>The loop is two conditional edges.</strong> model_to_tools and tools_to_model are the same kind of router as route in section 2. The cycle model → tools → model is the whole agent loop.</li><li><strong>Parallel tool calls are </strong><strong>Send.</strong> When the model asks for two tools at once, the router returns one Send(&quot;tools&quot;, [call]) per call, so each runs as its own task in the next superstep (the Send mechanism explained at the end of section 3). In our one-tool run there is just one pending call, so there is one task.</li></ul><p>The real function adds more nodes and edges when you pass middleware or a response format (that is what the extra ...middleware.before_agent nodes in the Deep Agents section are), but the skeleton is this.</p><h3>4.2 Using it</h3><p>Here is the code from the demo that calls create_agent. To stay offline and deterministic it uses a <em>scripted</em> model, a fake chat model that first asks to call the tool double(21) and then, once it sees the tool&#39;s result, answers. (A real agent would pass a real chat model in its place.)</p><pre>from langchain.agents import create_agent<br>from langchain_core.language_models import BaseChatModel<br>from langchain_core.messages import AIMessage, HumanMessage, ToolMessage<br>from langchain_core.outputs import ChatGeneration, ChatResult<br>from langchain_core.tools import tool</pre><pre>@tool                                    # turns a plain function into a tool the model can call<br>def double(n: int) -&gt; str:<br>    &quot;&quot;&quot;Double n.&quot;&quot;&quot;                      # the docstring is the description the model sees<br>    return str(n * 2)</pre><pre>class Scripted(BaseChatModel):           # a fake model with a fixed script<br>    @property<br>    def _llm_type(self): return &quot;scripted&quot;<br>    def bind_tools(self, tools, **kw): return self.bind(tools=tools, **kw)<br>    def _generate(self, messages, stop=None, run_manager=None, **kw):<br>        done = [m for m in messages if isinstance(m, ToolMessage)]<br>        if not done:                     # turn 1: no tool result yet, so ask for the tool<br>            msg = AIMessage(content=&quot;&quot;, id=&quot;ai-1&quot;, tool_calls=[<br>                {&quot;name&quot;: &quot;double&quot;, &quot;args&quot;: {&quot;n&quot;: 21}, &quot;id&quot;: &quot;call-1&quot;, &quot;type&quot;: &quot;tool_call&quot;}])<br>        else:                            # turn 2: tool result is in the history, so answer<br>            msg = AIMessage(content=f&quot;answer {done[-1].content}&quot;, id=&quot;ai-2&quot;)<br>        return ChatResult(generations=[ChatGeneration(message=msg)])</pre><pre>agent = create_agent(Scripted(), tools=[double])      # one call builds the whole graph<br>out = agent.invoke({&quot;messages&quot;: [HumanMessage(&quot;double 21&quot;)]})</pre><p>Inspecting agent the same way as before prints:</p><pre>nodes   : [&#39;model&#39;, &#39;tools&#39;]<br>channels: {&#39;messages&#39;: &#39;BinaryOperatorAggregate&#39;, &#39;jump_to&#39;: &#39;EphemeralValue&#39;,<br>           &#39;structured_response&#39;: &#39;LastValue&#39;, &#39;__start__&#39;: ..., &#39;__pregel_tasks&#39;: ...}<br>node &#39;model&#39;: bound=RunnableCallable<br>node &#39;tools&#39;: bound=ToolNode<br>edges   : [(&#39;__start__&#39;,&#39;model&#39;), (&#39;model&#39;,&#39;__end__&#39;,cond), (&#39;model&#39;,&#39;tools&#39;,cond), (&#39;tools&#39;,&#39;model&#39;,cond)]<br>messages: HumanMessage &#39;double 21&#39; → AIMessage[tool_call double] → ToolMessage &#39;42&#39; → AIMessage &#39;answer 42&#39;</pre><h3>4.3 The graph it produced</h3><p>The graph that create_agent built is small. Asking the compiled object to draw itself (agent.get_graph().draw_mermaid()) gives this, where dashed arrows are conditional edges:</p><pre>graph TD;<br>    __start__([__start__]) --&gt; model(model)<br>    model -.-&gt;|tool calls present| tools(tools)<br>    model -.-&gt;|no tool calls| __end__([__end__])<br>    tools -.-&gt; model</pre><p>This is what get_graph().draw_mermaid() produced (I ran it), with the two edge labels added by me to explain the routing. Walking it with our run: __start__ puts the HumanMessage into messages and wakes model. The scripted model replies with a tool call, so the router sends the run to tools. tools executes double(21) and appends a ToolMessage(&#39;42&#39;), which wakes model again. This time the reply has no tool calls, so the router goes to __end__. That is the messages: line above: two trips through model, one through tools.</p><p>That is the whole ReAct loop:</p><ul><li>State is a messages channel with an <em>append</em> reducer (so the conversation accumulates, just like visited above).</li><li>model is a node that calls the chat model. tools is a ToolNode: a Runnable that reads the last AIMessage&#39;s tool_calls, runs each tool and writes ToolMessages.</li><li>The loop is a <strong>conditional edge</strong> out of model: tool calls present → tools, otherwise → __end__. And tools → model is a plain back-edge. A cycle in the graph is the agent loop; the Pregel loop just keeps ticking while something is triggered.</li></ul><p>Nothing agent-specific exists in the engine. “Agent” is a graph <em>shape</em>.</p><h3>5. Graph bubble-up</h3><p>A quick way to place this next to Send from the previous section: both are things a node or router hands to the loop, but in opposite directions. Send is <strong>data going down</strong>: a request to <em>create</em> tasks, written to a channel and picked up at the next barrier. Bubble-up is a <strong>control signal going up</strong>: an exception that <em>stops</em> work and travels from inside a node to the code that owns the run. They are separate mechanisms. They meet in one place: a task created by Send is an ordinary task, so if it calls interrupt() or raises a GraphBubbleUp, that signal bubbles up through the loop like any other (for example, an agent whose tool asks a human for approval).</p><p>Some things a node does are not return values. interrupt(&quot;approve?&quot;) has to stop the whole run, not just the node. LangGraph implements that as an exception, much like throwing a custom exception in C# that a catch further up the stack deliberately handles:</p><pre>GraphBubbleUp(Exception)<br>  ├─ GraphDrained        (cooperative drain)<br>  ├─ GraphInterrupt      (interrupt(), human-in-the-loop)<br>  │    └─ NodeInterrupt<br>  └─ ParentCommand       (a subgraph telling its parent what to do)</pre><p>“Bubble up” means exactly that: the exception is raised inside the node and travels <em>up</em> through the runner. Per _retry.py and _runner.py:</p><ul><li>the retry wrapper (run_with_retry) deliberately does <strong>not</strong> retry a GraphBubbleUp; ordinary exceptions go through your RetryPolicy;</li><li>a GraphInterrupt has its pending writes saved to the checkpointer, so the pause survives;</li><li>a ParentCommand is handled by the enclosing graph, which is how a Command(graph=Command.PARENT, ...) from a subgraph reaches its parent; a root graph suppresses a GraphInterrupt and returns the interrupt in its result rather than raising.</li></ul><p>Command is just a small dataclass of four optional fields: update, goto, resume, graph. You use it in two places, and a different pair of fields matters in each:</p><ul><li><strong>Returned from a node</strong>, to do “update state <em>and</em> route” in one step: Command(update={&quot;total&quot;: 1}, goto=&quot;b&quot;). update is applied like a normal node return value, and goto (a node name, a list of names, or Send packets) picks what runs next without needing an edge. graph=Command.PARENT aims it at the enclosing graph instead of the current one (that is the ParentCommand above).</li><li><strong>Passed as the input to </strong><strong>invoke</strong>, to continue a paused run: Command(resume=&quot;yes&quot;). Normally invoke takes new state; here it is told &quot;don&#39;t start over, the saved run is waiting for an answer, and this is it&quot;. resume is the value that the pending interrupt() call returns.</li></ul><p>The demo below uses the second form. The first form (update/goto) is shown in section 5c.</p><p>The demo shows the whole cycle with an in-memory saver. First the graph, with one node that calls interrupt(). A checkpointer is required, because the pause has to be saved somewhere:</p><pre>from langgraph.checkpoint.memory import InMemorySaver<br>from langgraph.types import Command, interrupt</pre><pre>def ask(s):<br>    answer = interrupt(&quot;approve?&quot;)       # pauses the whole run here; returns the resume value next time<br>    return {&quot;visited&quot;: [f&quot;answer={answer}&quot;]}</pre><pre>h = StateGraph(S)                        # same state class S as before<br>h.add_node(&quot;ask&quot;, ask)<br>h.add_edge(START, &quot;ask&quot;)<br>h.add_edge(&quot;ask&quot;, END)<br>happ = h.compile(checkpointer=InMemorySaver())<br>cfg = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;t&quot;}}   # identifies the saved conversation/run</pre><pre>print(&quot;first call :&quot;, happ.invoke({&quot;total&quot;: 0, &quot;visited&quot;: []}, cfg))   # runs until interrupt(), returns<br>print(&quot;next       :&quot;, happ.get_state(cfg).next)                        # which node is waiting<br>print(&quot;resumed    :&quot;, happ.invoke(Command(resume=&quot;yes&quot;), cfg))         # continue, answering &quot;yes&quot;</pre><p>which prints (the interrupt id is shortened):</p><pre>first call : {&#39;total&#39;: 0, &#39;visited&#39;: [], &#39;__interrupt__&#39;: [Interrupt(value=&#39;approve?&#39;, id=&#39;014f…&#39;)]}<br>next       : (&#39;ask&#39;,)<br>resumed    : {&#39;total&#39;: 0, &#39;visited&#39;: [&#39;answer=yes&#39;]}</pre><p>Reading it: the first invoke does not raise. It returns normally, with the pending question under __interrupt__. get_state(cfg).next says node ask is the one waiting. The second invoke is given a Command(resume=&quot;yes&quot;) instead of new input; it finds the saved checkpoint for the same thread_id and continues.</p><p>Note what “resume” really is: the node runs <strong>again from the top</strong>, and this time interrupt() returns the resume value instead of raising. That re-execution is why side effects before an interrupt() must be idempotent.</p><h3>6. And Deep Agents</h3><p><strong>Where it comes from.</strong> create_deep_agent lives in the deepagents package, a separate open-source (MIT) project from LangChain, published at langchain-ai/deepagents. Its package metadata describes it as an &quot;agent harness with a built-in filesystem and context management, sub-agent delegation, skills, and long-term memory&quot;, and marks it Beta (I used 0.7.20). It is not part of langgraph or langchain; it depends on langchain. Its source imports create_agent from langchain.agents, and its docstring says several arguments are &quot;passed through to create_agent&quot;.</p><p><strong>Where it sits in the stack.</strong> Think of three layers, each built on the one below:</p><pre>deepagents   create_deep_agent   a ready-made &quot;harness&quot;: files, subagents, summarization, skills, memory<br>   |  calls<br>langchain    create_agent        the model &lt;-&gt; tools loop, plus the middleware system<br>   |  builds<br>langgraph    StateGraph / Pregel the runtime: channels, supersteps, checkpoints</pre><p>In C# terms: LangGraph is the runtime (like ASP.NET’s host and pipeline), create_agent is a minimal app template, and Deep Agents is the opinionated starter kit that pre-installs a set of middleware on that template. Which is why the graph below looks the same as section 4&#39;s.</p><p>create_deep_agent(model=..., tools=[...]) also returns a CompiledStateGraph, with the same model and tools nodes, plus a middleware node. What the demo printed:</p><pre>nodes   : [&#39;model&#39;, &#39;tools&#39;, &#39;PatchToolCallsMiddleware.before_agent&#39;]<br>channels: messages: DeltaChannel, files: DeltaChannel,<br>          _summarization_event, _summarization_session_id, ...<br>tools the model sees: [&#39;delete&#39;,&#39;double&#39;,&#39;edit_file&#39;,&#39;execute&#39;,&#39;glob&#39;,&#39;grep&#39;,&#39;ls&#39;,&#39;read_file&#39;,&#39;task&#39;,&#39;write_file&#39;]<br>edges   : ... (&#39;model&#39;,&#39;model&#39;,cond), (&#39;model&#39;,&#39;tools&#39;,cond), (&#39;tools&#39;,&#39;model&#39;,cond) ...</pre><p>So Deep Agents is the agent graph with extra <em>state</em> and extra <em>tools</em>, added by middleware:</p><ul><li><strong>Extra channels:</strong> files (a virtual file system kept in graph state) and summarization bookkeeping for long conversations. messages is a DeltaChannel here rather than a plain reducer channel, so I wouldn&#39;t assume the agent&#39;s channel types equal the plain agent&#39;s.</li><li><strong>Extra tools:</strong> ls, read_file, write_file, edit_file, glob, grep, execute from the filesystem middleware, and task. execute is listed to the model, but the default StateBackend has no execute (I checked: hasattr(StateBackend, &quot;execute&quot;) is False); it only runs when the backend implements SandboxBackendProtocol, otherwise it returns an error. So no shell access by default, and files is ephemeral graph state.</li><li><strong>A middleware hook as a node:</strong> PatchToolCallsMiddleware.before_agent runs before the first model call (it repairs dangling tool calls). Only middleware that overrides before_agent, before_model, after_model or after_agent adds nodes (I confirmed M.before_model and M.after_model appear in nodes); middleware that wraps model or tool calls does not.</li></ul><h3>What is middleware?</h3><p>Middleware is how create_agent is extended without editing the loop. It is the same idea as ASP.NET middleware or DelegatingHandler: you write a class, hand a list of them to create_agent(..., middleware=[...]), and the loop calls your hooks at defined points. AgentMiddleware has two families of hooks (from langchain/agents/middleware/types.py):</p><ul><li><strong>Node hooks</strong>, which run <em>between</em> steps: before_agent, before_model, after_model, after_agent. They receive the state and return a state update (or None), and can set jump_to to redirect the loop. Each one you override becomes a <strong>real node in the graph</strong>.</li><li><strong>Wrap hooks</strong>, which run <em>around</em> a call: wrap_model_call(request, handler) and wrap_tool_call(request, handler). You call handler(request) to continue, so you can change the request, retry, short-circuit or post-process the response. These live inside the existing model and tools nodes, so they add no nodes.</li></ul><p>A middleware can also contribute tools and extra state keys. I ran this to check:</p><pre>class Audit(AgentMiddleware):<br>    def before_model(self, state, runtime):             # a node hook<br>        log.append(f&quot;before_model: {len(state[&#39;messages&#39;])} msgs&quot;); return None<br>    def wrap_model_call(self, request, handler):        # a wrap hook<br>        log.append(&quot;wrap_model_call: enter&quot;)<br>        resp = handler(request)                         # the next layer, ending in the model<br>        log.append(&quot;wrap_model_call: exit&quot;); return resp</pre><pre>agent = create_agent(fake_model, middleware=[Audit()])</pre><pre>nodes: [&#39;Audit.before_model&#39;, &#39;__end__&#39;, &#39;__start__&#39;, &#39;model&#39;]<br>log  : [&#39;before_model: 1 msgs&#39;, &#39;wrap_model_call: enter&#39;, &#39;wrap_model_call: exit&#39;]</pre><p>Audit.before_model is a new node; wrap_model_call added nothing to the graph but ran around the model call. That is exactly the PatchToolCallsMiddleware.before_agent node in the Deep Agents output above. Deep Agents is mostly a curated middleware list (filesystem, summarization, tool-call patching, optional skills and memory).</p><h3>What is the state backend?</h3><p>Deep Agents’ file tools (ls, read_file, write_file, edit_file, glob, grep) do not touch the disk directly. They call a <strong>backend</strong>, an object implementing BackendProtocol (ls, read, write, edit, grep, glob, delete, upload/download). The backend decides where files actually live, so the same agent can run against different storage. It is a repository/strategy interface, like IFileProvider in .NET. The backends in the package:</p><p>Backend Where files live StateBackend (the default) In the graph state&#39;s files channel. Ephemeral: persists within a thread through checkpoints, not across threads StoreBackend A LangGraph BaseStore, so persistent across conversations FilesystemBackend The real disk LocalShellBackend The real disk plus unrestricted local shell (execute) BaseSandbox (and subclasses) A sandbox that implements execute() CompositeBackend Routes by path prefix to other backends (for example /memories/ to a store, the rest to state)</p><p>Note: this is <strong>not</strong> the same thing as a LangGraph checkpointer, which is also sometimes called a state backend (section 5b). The checkpointer stores <em>the graph’s</em> snapshots; the Deep Agents backend stores <em>the agent’s files</em>. With the default StateBackend the two meet: its docstring says reads and writes go through CONFIG_KEY_READ and CONFIG_KEY_SEND, so a file write is just an ordinary channel write to files, applied at the barrier and checkpointed like any other state. That is why files showed up as a channel in the output above, and why the default needs no disk. StateBackend raises if used outside a graph run; to pre-seed files, pass {&quot;files&quot;: {...}} to invoke.</p><blockquote><strong><em>Side note: the same pattern in the Squad SDK.</em></strong><em> Squad’s SDK (</em><a href="https://github.com/bradygaster/squad"><em>bradygaster/squad</em></a><em>, </em><em>packages/squad-sdk) has the matching idea in its </em><strong><em>StorageProvider</em></strong><em> interface (</em><em>read, </em><em>write, </em><em>append, </em><em>list, </em><em>delete, </em><em>mkdir, </em><em>rename, </em><em>stat, </em><em>createIfAbsent...), with </em><em>FSStorageProvider (default), </em><em>InMemoryStorageProvider and </em><em>SQLiteStorageProvider: a narrow interface, several implementations, one default. It was added by Dina Berry (</em><em>diberry; </em><a href="https://github.com/bradygaster/squad/pull/567"><em>#567</em></a><em>, </em><a href="https://github.com/bradygaster/squad/pull/640"><em>#640</em></a><em>). Squad also has a separate, higher-level </em><em>StateBackend (working-tree files, an orphan git branch, or that plus git notes), which is </em>not<em> the same thing: an adapter wraps it as a </em><em>StorageProvider. Source, pinned to the commit I read: </em><a href="https://github.com/bradygaster/squad/blob/e0c84dd0d32189ba0744cf5bb717906e02801215/packages/squad-sdk/src/storage/storage-provider.ts"><em>storage-provider.ts</em></a><em>, </em><a href="https://github.com/bradygaster/squad/blob/e0c84dd0d32189ba0744cf5bb717906e02801215/packages/squad-sdk/src/state-backend.ts"><em>state-backend.ts</em></a><em>, </em><a href="https://github.com/bradygaster/squad/blob/e0c84dd0d32189ba0744cf5bb717906e02801215/docs/src/content/docs/features/state-backends.md"><em>State Backends docs</em></a><em>. Back to Deep Agents.</em></blockquote><h3>What is DeltaChannel?</h3><p>Both messages and files in the Deep Agents graph are DeltaChannels instead of the plain reducer channel. The problem it solves: a normal reducer channel checkpoints its full value every step, so a long conversation or a large virtual file system is copied into every checkpoint. DeltaChannel (beta, langgraph/channels/delta.py) instead stores only a <strong>sentinel</strong> in ordinary checkpoints and rebuilds the value by replaying the earlier writes through the reducer, writing a full snapshot every so often (every 1000 updates by default). It is event sourcing: the log of writes is the truth, snapshots are an optimization.</p><p>The price is in its own docstring: the reducer must be deterministic and <em>batching-invariant</em> (reducer(reducer(s, xs), ys) == reducer(s, xs + ys)), and the checkpoint saver must keep the ancestor writes (get_delta_channel_history). Section 5b covers what this means for checkpoints.</p><h3>Deep Agents capability inventory</h3><p>From the installed package layout (deepagents/middleware and deepagents/backends), and what I observed above:</p><ul><li><strong>Middleware modules:</strong> filesystem, subagents, async subagents, summarization, memory, skills, permissions, rubric, patch-tool-calls, prompt caching, message eviction, overflow clipping, unsupported-content, tool exclusion.</li><li><strong>Backends</strong> (where files/execute actually go): state (graph state, the default seen in the demo), store, composite, filesystem, local shell, sandbox, LangSmith, context hub.</li><li>Only middleware that defines before_*/after_* hooks becomes a graph node (we saw PatchToolCallsMiddleware.before_agent); the others wrap the model or tool call in place, so they don&#39;t appear in nodes. I inferred that rule from this one observed node and the module layout, not from reading the middleware loader.</li><li>In this default build the model’s tools were the seven filesystem/shell tools, task, and my own double. I did not see a todo-list tool, so I won&#39;t claim one.</li></ul><h3>task is a graph calling a graph</h3><p>The subagent mechanism is the most interesting part, and it is ordinary LangGraph. In deepagents/middleware/subagents.py, task is a StructuredTool. Its body prepares a fresh state (for a normal subagent just {&quot;messages&quot;: [HumanMessage(description)]} plus non-private state keys), then calls:</p><pre>result = subagent.invoke(subagent_state, subagent_config)<br>return _return_command_with_state_update(result, runtime.tool_call_id)</pre><p>Because a compiled graph is a Runnable (section 1), the subagent is simply another compiled agent invoked from <em>inside a tool call</em>. Its result comes back as a Command(update={..., &quot;messages&quot;: [ToolMessage(...)]}), which the parent&#39;s ToolNode applies to the parent&#39;s channels. The subagent&#39;s context window is isolated; only its final message and selected state keys return.</p><h3>5b. Reducers, channel types and checkpoints, for real</h3><p><strong>Checkpoints.</strong> With a checkpointer, each superstep boundary writes a row. The demo saves the two-node graph in an InMemorySaver and lists it:</p><pre>step=-1 source=input values={&#39;__start__&#39;: {&#39;total&#39;: 10, &#39;visited&#39;: []}}<br>step= 0 source=loop  values={&#39;total&#39;: 10, &#39;visited&#39;: []}<br>step= 1 source=loop  values={&#39;total&#39;: 20, &#39;visited&#39;: [&#39;a&#39;]}<br>step= 2 source=loop  values={&#39;total&#39;: 21, &#39;visited&#39;: [&#39;a&#39;, &#39;b&#39;]}<br>versions_seen keys: [&#39;__input__&#39;, &#39;__start__&#39;, &#39;a&#39;, &#39;b&#39;]<br>channel_versions: {&#39;__start__&#39;: &#39;2&#39;, &#39;total&#39;: &#39;4&#39;, &#39;visited&#39;: &#39;4&#39;, &#39;branch:to:a&#39;: &#39;3&#39;, &#39;branch:to:b&#39;: &#39;4&#39;}</pre><p>A checkpoint is a small dict: channel_values, channel_versions, versions_seen, updated_channels, an id, a timestamp and v. Two fields do the scheduling:</p><ul><li>channel_versions is a monotonically increasing version per channel (the real strings are zero-padded counters plus a random suffix; I shortened them).</li><li>versions_seen[node] records which trigger version each node last consumed. prepare_next_tasks runs a node when a trigger channel&#39;s version is newer than what that node has seen. That is the whole &quot;what runs next&quot; rule, and it is why resuming from a checkpoint needs no extra bookkeeping.</li></ul><p>Step -1 stores the raw input; steps 0..N are loop supersteps. Writes made by tasks that have run but not yet been applied are stored separately as <em>pending writes</em> against their parent checkpoint (put_writes). That is how a crash in the middle of a parallel superstep doesn&#39;t lose the branches that already finished, and how interrupt state survives.</p><p><strong>The saver (state backend) contract.</strong> BaseCheckpointSaver is the storage seam. The core methods are put (a checkpoint), put_writes (pending writes for a task), get_tuple and list (read), with async twins (aput, aget_tuple, ...), plus thread deletion/pruning helpers and a serde (here JsonPlusSerializer). Rows are addressed by thread_id, checkpoint_ns (the subgraph namespace) and checkpoint_id; a new thread id is a new empty history. I only ran InMemorySaver; I haven&#39;t exercised the SQLite or Postgres savers, so I make no claim about them.</p><p><strong>Caveat: </strong><strong>DeltaChannel.</strong> Deep Agents&#39; messages and files use DeltaChannel (beta in langgraph/channels/delta.py). Per my reading of that source, not a run, it checkpoints a sentinel on ordinary steps and rebuilds the value from ancestor writes, with periodic full snapshots, so it needs deterministic reducers and savers that keep ancestor history. &quot;Each checkpoint is a full snapshot&quot; holds only for ordinary channels.</p><h3>5c. When things get structured or go wrong: conflicts, routing, subgraphs, retry</h3><p>The earlier sections showed the happy path. This one covers what happens when things go wrong or get more structured: two nodes fight over a key, a node needs to both update state and choose where to go, a graph contains another graph, a node throws, and you want to control how often state is saved or watch it live. I ran every snippet below offline (structured_cases.py in the samples repo); outputs are quoted as seen.</p><h3>Two parallel nodes write the same key</h3><p>Remember the superstep rule from section 3: nodes in the same superstep all read the old state, and their writes are applied together at the barrier. So what if two of them write the same key? For a plain field like total: int, the channel is a LastValue, which can hold only one value. &quot;Which of the two writes wins?&quot; has no sane answer, so LangGraph refuses to guess:</p><pre>class S(TypedDict):<br>    total: int</pre><pre>g = StateGraph(S)<br>g.add_node(&quot;a&quot;, lambda s: {&quot;total&quot;: 1})<br>g.add_node(&quot;b&quot;, lambda s: {&quot;total&quot;: 1})<br>g.add_edge(START, &quot;a&quot;)                           # a and b both start from START,<br>g.add_edge(START, &quot;b&quot;)                           # so they run in the same superstep<br>g.add_edge(&quot;a&quot;, END)<br>g.add_edge(&quot;b&quot;, END)<br>g.compile().invoke({&quot;total&quot;: 0})</pre><pre>InvalidUpdateError: At key &#39;total&#39;: Can receive only one value per step. Use an Annotated key to handle multiple values.</pre><p>The error message already contains the fix. If you declare the key as Annotated[list, operator.add], the channel becomes a BinaryOperatorAggregate, which knows how to combine several writes, and the same graph returns {&#39;total&#39;: [&#39;a&#39;, &#39;b&#39;]}. In C# terms: it is the difference between two threads assigning the same field (a race you must forbid) and two threads calling list.AddRange through a lock (a merge you defined). The reducer <em>is</em> the lock.</p><h3>Command: update state and choose the next node in one return</h3><p>Normally a node returns a state update, and the <em>edges</em> decide what runs next. Sometimes the node itself knows where to go (a router, an agent deciding to hand off). Returning a Command lets it do both at once, as defined in section 5:</p><pre>def router(s):<br>    return Command(update={&quot;out&quot;: [&quot;r&quot;]}, goto=&quot;t&quot;)   # write state AND pick the next node</pre><pre>g = StateGraph(C)                       # C has: total: int, out: Annotated[list, operator.add]<br>g.add_node(&quot;r&quot;, router)<br>g.add_node(&quot;t&quot;, lambda s: {&quot;out&quot;: [&quot;t&quot;]})<br>g.add_edge(START, &quot;r&quot;)<br>g.add_edge(&quot;t&quot;, END)                                   # note: no edge from r to t<br>g.compile().invoke({&quot;total&quot;: 0, &quot;out&quot;: []})</pre><pre>{&#39;total&#39;: 0, &#39;out&#39;: [&#39;r&#39;, &#39;t&#39;]}</pre><p>There is no r → t edge in the graph. The goto created that routing at run time, and out shows both nodes ran, in order. The other two uses of Command you already met: resume= to continue after an interrupt (section 5), and graph=Command.PARENT to send the update to the <em>enclosing</em> graph when you are inside a subgraph (next subsection). Send (section 3) is the third routing tool: use goto to pick one next node, Send to start many tasks with different inputs.</p><h3>A graph inside a graph (subgraphs)</h3><p>Because a compiled graph is a Runnable (section 1), you can pass it to add_node like any function. The parent treats it as one node; inside, it runs its own supersteps. Its checkpoints live in their own namespace so they don&#39;t collide with the parent&#39;s, and stream(..., subgraphs=True) lets you see inside:</p><pre>child = StateGraph(C)                        # C is the same state schema as above<br>child.add_node(&quot;t&quot;, lambda s: {&quot;out&quot;: [&quot;t&quot;]})<br>child.add_edge(START, &quot;t&quot;)<br>child.add_edge(&quot;t&quot;, END)</pre><pre>parent = StateGraph(C)<br>parent.add_node(&quot;child&quot;, child.compile())    # a compiled graph used as a node<br>parent.add_edge(START, &quot;child&quot;)<br>parent.add_edge(&quot;child&quot;, END)<br>app = parent.compile(checkpointer=InMemorySaver())</pre><pre>cfg = {&quot;configurable&quot;: {&quot;thread_id&quot;: &quot;1&quot;}}<br>for event in app.stream({&quot;total&quot;: 0, &quot;out&quot;: []}, cfg, subgraphs=True):<br>    print(event)</pre><pre>((&#39;child:512f5449-103f-f070-0e96-928c949f2c95&#39;,), {&#39;t&#39;: {&#39;out&#39;: [&#39;t&#39;]}})<br>((), {&#39;child&#39;: {&#39;total&#39;: 0, &#39;out&#39;: [&#39;t&#39;]}})</pre><p>Each event is (path, update). The first has a non-empty path: node t finished <em>inside</em> the child. The second has the empty path (): the parent sees the whole child node finish. The path is the namespace. This is also why the task tool from section 6 is &quot;a graph calling a graph&quot;.</p><h3>Retry: what happens when a node throws</h3><p>A node that raises normally fails the whole run. Attach a RetryPolicy and the loop reruns just that node instead. One gotcha I hit: the default retry_on deliberately <em>excludes</em> ValueError (programming-error exceptions are not retried by default), so my flaky node needed retry_on=ValueError explicitly:</p><pre>def flaky(s):<br>    n[&quot;i&quot;] += 1<br>    if n[&quot;i&quot;] &lt; 3: raise ValueError(&quot;boom&quot;)        # fails twice, then works<br>    return {&quot;out&quot;: [&quot;ok&quot;]}</pre><pre>g.add_node(&quot;f&quot;, flaky, retry_policy=RetryPolicy(retry_on=ValueError, initial_interval=0.01))</pre><pre>{&#39;total&#39;: 0, &#39;out&#39;: [&#39;ok&#39;]}      # n[&quot;i&quot;] == 3: two failures, third attempt succeeded</pre><p>The retry is around the <em>node</em>, not the graph, so other nodes’ finished work in the same superstep is not repeated. And the control-flow exceptions from section 5 (GraphBubbleUp: interrupts, parent commands) are never retried, because they are not failures. In C# this is Polly&#39;s Retry policy wrapped around one call, with the exception filter being retry_on.</p><h3>Saving less often, and watching live</h3><p>Two knobs control the cost and visibility of all this machinery:</p><ul><li><strong>durability</strong> on invoke/stream decides <em>when</em> checkpoints are written: &#39;sync&#39; (before the next step starts; safest, slowest), &#39;async&#39; (in the background while the next step runs), or &#39;exit&#39; (only when the run ends; fastest, but a crash loses the in-flight run). These descriptions are from the parameter&#39;s documented meaning, not something I measured. The signature default is None; I did not verify what that resolves to, so I don&#39;t state an effective default.</li><li><strong>Stream modes</strong> choose what stream emits: values (full state after each step), updates (just each node&#39;s return, which is what you saw above), checkpoints, tasks, debug, messages (LLM tokens), and custom (whatever your node writes via the stream writer). Same run, different views.</li></ul><h3>Deep Agents extras (listed, not exercised)</h3><p>From the module inventory only, I did not run these: skills, memory (prompt injection or a BaseStore), permissions, and interrupt_on human-in-the-loop. Subagents can be any compatible runnable, and task isolates context by default; an experimental fork mode inherits parent state.</p><h3>Reading state back, and time travel</h3><p>get_state(cfg) returns a StateSnapshot (values, next, config, metadata, created_at, parent_config, tasks, interrupts), and get_state_history(cfg) walks the parent chain. <strong>Time travel</strong> is just picking an older snapshot and invoking from it:</p><pre>state before b = {&#39;total&#39;: 20, &#39;visited&#39;: [&#39;a&#39;]}  -&gt;  invoke(None, that config) = {&#39;total&#39;: 21, &#39;visited&#39;: [&#39;a&#39;, &#39;b&#39;]}</pre><p><strong>The Store is a different thing.</strong> A checkpointer is per-thread run state. BaseStore (demo: InMemoryStore) is cross-thread long-term memory addressed by a namespace tuple and a key: store.put((&quot;users&quot;,&quot;tamir&quot;), &quot;pref&quot;, {...}). Deep Agents&#39; memory and store-backed file backends build on this idea; I only verified the plain InMemoryStore here.</p><h3>A final thought</h3><p>What stayed with me after reading all this code is how little magic there is. A graph is a set of channels and the nodes that wake when those channels change. Edges are just writes to channels, supersteps give you a clean read, write, merge rhythm, and the occasional special exception or Command is how a node steers the loop from the inside. An agent is that same machine with a model node and a tool node in a cycle, and a Deep Agent is the same machine again, with middleware adding channels, tools and hook nodes. Once I saw it that way, the docs, the stack traces and the checkpoints all started to make sense, and I stopped treating LangGraph as a black box.</p><p>If you want to poke at it yourself, everything in this post runs offline, with no API key, in the <a href="https://github.com/tamirdresher/langgraph-internals-samples">samples repo</a>. Pin the versions from the top of the post, because the internals move between releases.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3c523f4d6203" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Durable Task in the Real World: What Problems Does It Actually Solve?]]></title>
            <link>https://medium.com/@tamir_dresher/durable-task-in-the-real-world-what-problems-does-it-actually-solve-980b0098c6e8?source=rss-21fc7aa0950d------2</link>
            <guid isPermaLink="false">https://medium.com/p/980b0098c6e8</guid>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[durable-task]]></category>
            <category><![CDATA[azure]]></category>
            <dc:creator><![CDATA[Tamir Dresher]]></dc:creator>
            <pubDate>Sun, 27 Sep 2026 21:00:39 GMT</pubDate>
            <atom:updated>2026-10-05T05:06:26.156Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*0vZj8daGp0RRz-e5.png" /></figure><p>After my last couple of posts about Durable Task, I got a comment that made me realize I had skipped an important question:</p><p><em>This all looks interesting, but what are the actual use cases?</em></p><p>Not how Durable Task works. Not what happens during replay or how the scheduler persists state. But the more practical question: what problems does it actually solve?</p><p>When should I reach for durable execution instead of regular C#, a queue, a database, or a background worker? And when would adding it just make things more complicated?</p><p>Those are fair questions. Rather than walk through another hello world orchestration, I want to look at real situations where things get messy, especially when work takes a long time, depends on external systems, or gets interrupted halfway through. I also want to be clear about what Durable Task does not solve for you.</p><p>Imagine a background worker creating a customer’s Azure environment. It provisions a network, starts a database deployment, creates storage, and deploys an application. Then the worker disappears. Azure might still be working, but your local variables and continuations are gone. Restarting the method is easy. Figuring out which operations completed and which are safe to repeat is the hard part.</p><p>Of course, you could build your own solution with a database, a queue, an outbox, scheduled jobs, and reconciliation logic. Sometimes that’s exactly what you should do. But once you find yourself building a whole system just to remember what happens next and how to resume it, durable orchestration starts to get interesting.</p><p>I’ll walk through five examples: invoice approvals, Azure tenant provisioning, subscription payments, breaking news articles arriving through out of order webhooks, and an AI agent investigating an incident. We’ll use the standalone <a href="https://github.com/microsoft/durabletask-dotnet/releases/tag/v1.26.0">Durable Task .NET SDK v1.26.0</a> with DTS, and I’ll show both the useful parts and the responsibilities that still belong to our application.</p><p>If you want the background first, I covered <a href="https://www.tamirdresher.com/blog/2026/09/21/durable-await-replay">what happens at a durable await</a> and <a href="https://www.tamirdresher.com/blog/2026/09/25/durable-task-hidden-capabilities">three less obvious Durable Task capabilities</a> in the previous two posts.</p><p><strong>A note on the examples:</strong> The snippets below are deliberately focused on orchestration boundaries. Application adapters, DTO definitions, registrations, and some helper implementations are omitted. They illustrate the design rather than constituting a compiled, tested sample. In particular, do not infer exactly-once external effects from durable history.</p><h3>1. An invoice needs approval. Why should the worker remember the wait?</h3><p>Consider an invoice-processing service. It extracts a document, validates the result, and either posts the invoice, rejects it, or asks a human to review it. A batch may contain eight invoices, only one of which needs approval. The other seven shouldn’t wait for it.</p><p>Here’s an ordinary C# implementation of the individual processing path:</p><pre>public static async Task&lt;InvoiceResult&gt; ProcessAsync(<br>    InvoiceJob invoice, IInvoiceApplication app, CancellationToken ct)<br>{<br>    InvoiceCandidate candidate = await app.ExtractInvoiceAsync(invoice, ct);<br>    InvoiceAssessment assessment = await app.ValidateInvoiceAsync(candidate, ct);<br><br>    if (assessment.Check == InvoiceCheck.NeedsReview)<br>    {<br>        await app.OpenInvoiceReviewAsync(<br>            new InvoiceReview(invoice, assessment.PayloadReference), ct);<br>        return new(invoice.InvoiceKey, &quot;AwaitingReview&quot;);<br>    }<br><br>    if (assessment.Check == InvoiceCheck.Rejected)<br>        return new(invoice.InvoiceKey, &quot;Rejected&quot;);<br><br>    if (assessment.Check != InvoiceCheck.Valid)<br>        throw new InvalidOperationException(&quot;Unrecognized invoice assessment.&quot;);<br><br>    AccountingReceipt receipt = await app.SubmitApprovedInvoiceAsync(<br>        new InvoiceCandidate(invoice, assessment.PayloadReference), ct);<br>    return new(invoice.InvoiceKey, &quot;Posted&quot;, receipt.EntryReference);<br>}</pre><p>There’s nothing inherently unreliable about this method. OpenInvoiceReviewAsync can persist a review ticket and notification outbox. But when someone approves the invoice tomorrow, <em>another</em> handler must load the right document generation, check the deadline, recover the approved payload, and continue. If the worker dies between extraction and validation, the application must decide whether to extract again or retrieve the saved result.</p><p>That continuation protocol is what Durable Task can express directly.</p><h3>Give each invoice its own durable history</h3><p>A bounded batch can start independent child orchestrations:</p><pre>public override async Task&lt;InvoiceResult[]&gt; RunAsync(<br>    TaskOrchestrationContext context, InvoiceBatch batch)<br>{<br>    if (batch.Invoices is null || batch.Invoices.Length is &lt; 1 or &gt; 8<br>        || batch.Invoices.Select(x =&gt; x.InvoiceKey).Distinct().Count()<br>           != batch.Invoices.Length)<br>        throw new ArgumentException(&quot;Supply 1 to 8 distinct invoices.&quot;);<br><br>    return await Task.WhenAll(batch.Invoices.Select(invoice =&gt;<br>        context.CallSubOrchestratorAsync&lt;InvoiceResult&gt;(<br>            nameof(ProcessInvoice), invoice)));<br>}</pre><p>Eight is an application admission limit, not a DTS quota. Larger batches can be paged and subject to tenant/global concurrency limits. A child that completes can post its invoice while another child remains suspended awaiting review. Task.WhenAll waits for every child; it does not undo a sibling&#39;s accounting entry if another child fails.</p><p>Inside the invoice child, extraction and validation are activities. Once their successful results are recorded in orchestration history, a compatible replay follows the same code path using those recorded results instead of repeating the activities. This is <strong>re-execution of orchestrator code against history</strong>, not restoration of a suspended CLR stack.</p><p>When validation requires review, the child can express the wait:</p><pre>var review = new InvoiceReview(invoice, assessment.PayloadReference);<br>await context.CallActivityAsync(&quot;OpenInvoiceReview&quot;, review);<br><br>TimeSpan remaining =<br>    invoice.ReviewDeadlineUtc - context.CurrentUtcDateTime;<br><br>if (remaining &gt; TimeSpan.Zero)<br>{<br>    try<br>    {<br>        InvoiceReviewHint hint =<br>            await context.WaitForExternalEvent&lt;InvoiceReviewHint&gt;(<br>                $&quot;invoice-review:{invoice.ReviewId}&quot;, remaining);<br><br>        if (hint.ProcessingId != invoice.ProcessingId<br>            || hint.ReviewId != invoice.ReviewId)<br>            throw new InvalidOperationException(&quot;Review identity mismatch.&quot;);<br>    }<br>    catch (OperationCanceledException)<br>    {<br>        // The durable wait timed out. Read the committed decision below.<br>    }<br>}<br><br>InvoiceDecision decision =<br>    await context.CallActivityAsync&lt;InvoiceDecision&gt;(<br>        &quot;ResolveInvoiceReview&quot;, review);</pre><p>The timer is durable. No thread is occupied while the invoice waits, and a replacement worker can replay the child to the same pending wait. But the event is <strong>only a hint</strong>. An authenticated endpoint must atomically commit a single terminal review decision to the application’s review store before signaling the child. ResolveInvoiceReview reads or closes that authoritative ticket, including when the timer fires. If approval was committed just before the deadline but the hint arrived just after it, the stored decision, not notification timing, determines the outcome.</p><p>Only a valid automatic assessment or a committed human approval may proceed to the accounting activity. That activity uses a stable posting key and immutable request fingerprint. If the accounting system accepted the post but the activity’s acknowledgement vanished, the integration must reconcile the same posting identity. Durable history cannot promise exactly-once accounting.</p><p>This is the part I like. Instead of building separate handlers to remember extraction, validation, the review wait, and posting, I can describe the whole process in one place and let Durable Task remember where it got to. I still need to authorize reviewers, persist their decisions, and make accounting submissions safe to repeat. If my existing continuation handlers already do all of that cleanly, I might not need another runtime.</p><h3>2. Tenant provisioning: the cloud already has a deployment engine</h3><p>Now consider onboarding a SaaS tenant. A TenantCreated event means the tenant record was accepted, not that its environment is ready. This design provisions a dedicated network, PostgreSQL server, storage with private access, and Container App; then it configures DNS, checks readiness, and activates the tenant.</p><p>The ordinary C# method is easy to read:</p><pre>public async Task&lt;ProvisionedEnvironment&gt; ProvisionAsync(<br>    EnvironmentRequest request,<br>    CancellationToken cancellationToken = default)<br>{<br>    NetworkResources network = await azure.CreateVirtualNetworkAsync(<br>        request, cancellationToken);<br><br>    Task&lt;PostgreSqlServerReference&gt; databaseTask =<br>        azure.CreatePostgreSqlServerAsync(<br>            request, network.PostgreSqlSubnet, cancellationToken);<br>    Task&lt;StorageAccountReference&gt; storageTask =<br>        azure.CreateStorageAccountAsync(<br>            request, network.StoragePrivateEndpointSubnet,<br>            cancellationToken);<br><br>    await Task.WhenAll(databaseTask, storageTask);<br><br>    ContainerAppReference application = await azure.DeployContainerAppAsync(<br>        request, network.ContainerAppsSubnet,<br>        await databaseTask, await storageTask, cancellationToken);<br><br>    await azure.CreateDnsRecordAsync(<br>        request, application.Endpoint, cancellationToken);<br>    await azure.WaitForApplicationReadyAsync(<br>        application.Endpoint, cancellationToken);<br><br>    return new ProvisionedEnvironment(<br>        request.EnvironmentId, application.Endpoint);<br>}</pre><p>The helpers above wrap Bicep/ARM operations and wait for terminal completion or readiness. They’re application abstractions, not Azure SDK methods. The parallel modules own disjoint resources and don’t compete over subnet updates.</p><p>Before adding DTS, ask an uncomfortable question: <strong>does ARM already solve this?</strong> For a single well-defined deployment, it may. ARM/Bicep already supports dependency ordering, parallel deployment, idempotent reapplication with stable identities, and server-side deployment state. A worker crash doesn’t erase an ARM deployment. Querying it and reapplying the same desired state may be sufficient.</p><p>The wider onboarding process is different. The application must acknowledge the tenant request, report progress, coordinate readiness with activation, survive external approval steps if any, and decide what to do with a partly provisioned environment. That’s where a business-level workflow can add value.</p><h3>Separate resource readiness from tenant activation</h3><p>First, the API authorizes and durably admits an immutable onboarding request, then returns 202 Accepted, a request ID, and a status URL. Accepted does not mean ready. The application records the tenant, environment, immutable specification, and ownership generation. Reusing the same idempotency key with the same specification returns the existing request; changing the specification is rejected.</p><p>The scheduler handoff is not magically atomic with the application’s database. Save the intent to start in an outbox alongside admission; dispatch it and reconcile the workflow identity. Repeatedly scheduling a new instance is not the same as resuming an existing one.</p><p>The outer orchestration can then express the business dependency:</p><pre>public override async Task&lt;TenantActivation&gt; RunAsync(<br>    TaskOrchestrationContext context, EnvironmentPlan plan)<br>{<br>    EnvironmentResult resources =<br>        await context.CallSubOrchestratorAsync&lt;EnvironmentResult&gt;(<br>            nameof(ProvisionEnvironment), plan);<br><br>    return await context.CallActivityAsync&lt;TenantActivation&gt;(<br>        &quot;ActivateTenant&quot;, new TenantActivationRequest(plan, resources));<br>}</pre><p>The child finishes at <strong>resource readiness</strong>. Activation is a separate, idempotent application activity that revalidates tenant ownership and actual resource readiness. If activation fails, the outer workflow fails visibly without automatically deleting the resources the child provisioned. Repairing activation is a different problem from rolling back provisioning.</p><p>Inside the child, stage dependencies form waves: network; database and storage in parallel; application; DNS; readiness. Each stage activity records an intent and starts or reconciles a provider operation. A separate read activity performs one bounded provider status query; the orchestrator schedules a durable timer between polls using context.CurrentUtcDateTime and context.CreateTimer.</p><pre>DateTime now = context.CurrentUtcDateTime;<br>if (now &gt;= deadline)<br>    return Failed(&quot;PollingDeadlineExceeded&quot;);<br><br>TimeSpan delay = TimeSpan.FromSeconds(progress.PollAfterSeconds);<br>DateTime wakeAt = delay &gt;= deadline - now<br>    ? deadline<br>    : now.Add(delay);<br><br>await context.CreateTimer(wakeAt, CancellationToken.None);</pre><p>No HTTP request or Task.Delay runs inside the orchestrator. The activity does the I/O; the orchestration history records the activity result and timer decision. A 45-minute polling budget limits additional polling. It <strong>does not cancel an Azure operation</strong> that was already accepted.</p><h3>Follow one failure all the way through</h3><p>Imagine the database deployment was accepted by Azure, but the worker died before the activity completion reached DTS:</p><ol><li>DTS does not have a recorded successful activity result, so activity redelivery is possible.</li><li>The redelivered adapter consults the application’s operation journal and the stable Azure deployment/resource identity.</li><li>If the deployment is still running, it resumes inquiry rather than starting an unrelated database.</li><li>Once the provider returns terminal completion, the activity records the result and the child continues toward application deployment.</li><li>The tenant is activated only after the resource child returns validated readiness evidence.</li></ol><p>Contrast that with a different failure: the activity completion <em>was</em> recorded, and only then the worker crashed. On compatible replay, the orchestrator consumes that result from history without invoking the completed activity again. These are different failure windows; durable execution history solves one, and your adapter’s reconciliation protocol solves the other.</p><h3>Compensation isn’t an undo button</h3><p>A failed workflow doesn’t mean Azure stopped working. Imagine that we’ve started creating a database and a storage account in parallel. Storage provisioning fails, but Azure is still creating the database. If we immediately delete everything we can see, that database might finish provisioning <em>after</em> cleanup has already checked for it. We’ve now reported failure and cleanup while an orphaned database continues generating charges.</p><p>Before cleaning up, the application needs to:</p><ol><li>Prevent this failed onboarding attempt from submitting any new provisioning operations.</li><li>Check previously submitted operations, including requests whose responses were lost, and reconcile their actual state. Don’t assume a timeout or a failed worker stopped Azure.</li><li>Delete only resources created exclusively for this attempt, and only after establishing that outstanding operations cannot create them again after deletion.</li></ol><p>Automatic cleanup also has a strict boundary: this must be a failed <strong>initial</strong> onboarding attempt, before tenant activation or customer-data admission. Existing shared resources and active customer data are never cleanup targets. Recheck current ownership and provider identity before each deletion, and delete eligible new resources in reverse dependency order: DNS, application, database/storage, then network.</p><p>If we cannot establish what is still running or what we safely own, retain the resources and surface NeedsIntervention for reconciliation rather than guessing. Record both the original provisioning failure and any cleanup failures. Durable Task can coordinate this recovery process, but it cannot decide which Azure resources are safe to delete.</p><blockquote><strong><em>Going deeper: fencing and draining in-flight operations</em></strong><em><br> Two related ideas explain why cleanup is harder than reversing completed steps:</em></blockquote><ul><li>prevents outdated attempts from making new changes, provided the system receiving those changes enforces the restriction. For example, our application can reject submissions from an older provisioning generation. This does <strong>not</strong> revoke requests Azure already accepted.</li><li><strong>Draining and reconciling in-flight operations</strong> means accounting for previously submitted work, including requests whose acknowledgements were lost, before declaring cleanup safe. Some distributed-systems discussions call reaching a state with no relevant outstanding work <strong>quiescence</strong> (from <em>quiescent</em>, meaning quiet or inactive). Here we’re describing a practical cleanup requirement, <strong>not</strong> the formal concurrency property <em>quiescent consistency</em> and not a guarantee Azure provides automatically.</li></ul><blockquote><em>For background, see </em><a href="https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html"><em>Martin Kleppmann’s explanation of fencing tokens</em></a><em> and </em><a href="https://learn.microsoft.com/en-us/rest/api/resources/deployments/cancel?view=rest-resources-2025-04-01"><em>Microsoft’s ARM deployment cancellation API</em></a><em>. Even cancellation may leave resources partially deployed. If we cannot establish that earlier requests can no longer create resources, we retain the environment for reconciliation rather than declaring cleanup complete.</em></blockquote><p>There is one thing to keep in mind here. Task.WhenAll waits for the whole provisioning wave, but if one of those activities throws, execution will not continue to the code that checks the results. In this example I treat expected failures as results and let unexpected failures fail the orchestration normally.</p><p>This also matters for cleanup. Waiting for all the provisioning tasks to finish tells me what succeeded and what failed, so I know what needs to be cleaned up. It does not automatically perform that cleanup. I still need to explicitly run the compensation steps for the resources that were already created.</p><p>Here, Durable Task is useful for connecting resource provisioning, the long waits, and tenant activation without writing my own continuation state machine. ARM still provisions the resources. My application still needs an outbox, operation identities, safe cleanup, and a way to repair uncertain outcomes. I would not add Durable Task just to rebuild the dependency graph ARM already gives me.</p><h3>3. A payment timeout is not a decline</h3><p>Suppose a subscription charge times out. The provider may have accepted the payment before the response disappeared. A naive retry risks charging twice; a naive decline path risks sending a misleading reminder.</p><p>A conventional implementation can persist NextCheckUtc, finish its handler, and let a scheduled job inquire later. That&#39;s a reasonable architecture. A durable orchestration becomes interesting when the recovery policy also includes due dates, callbacks, bounded reminders, a fixed grace deadline, and transition to the next billing cycle.</p><p>Before going further, <strong>a billing cycle means one billing period and its associated payment obligation</strong>, not one charge attempt or the entire subscription. For a €20 monthly subscription, September and October are separate cycles. Each has its own CycleId, due date, and initial PaymentKey; both belong to the same SubscriptionId. GraceUntilUtc sets the deadline for this cycle&#39;s automated recovery. If a payment times out, the cycle remains the same while the service investigates whether that attempt actually succeeded.</p><p>The distinction matters for ContinueAsNew. Once a paid receipt is confirmed and the billing service authoritatively plans the next cycle, the orchestrator can continue with <strong>new cycle input</strong> while retaining its instance ID and starting a fresh execution history:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/840/1*cKDOoFXQUR4zLeERNaWjMA.png" /><figcaption>Paid billing cycles advance with new cycle input; uncertain payments remain in reconciliation.</figcaption></figure><p>This is a conceptual lifecycle, not a promise that each month will be charged successfully. An unresolved cycle does <strong>not</strong> automatically advance, and the billing service, not the orchestrator, decides whether a next cycle is permitted.</p><p>Here the billing ledger, not DTS, is authoritative. The billing store binds a cycle ID and stable payment key to the amount and currency. Payment state distinguishes Unknown, Pending, Declined, Paid, and NotCharged, with cancellation and receipt information. The workflow coordinates inquiries and reminders. A separate payment service owns any newly authorized charge after card correction.</p><p>The orchestration waits until the cycle’s due date with a durable timer, then calls an activity that either initiates the initial payment <strong>only when authorized and definitively not submitted</strong>, or reconciles an existing paid, pending, or unknown attempt. Its recovery loop checks state and waits for a callback hint or the next six-hour check, whichever comes first:</p><pre>DateTime now = context.CurrentUtcDateTime;<br>if (now &gt;= cycle.GraceUntilUtc || check == 15)<br>    break;<br><br>if (!reminderSent &amp;&amp; !payment.SubscriptionCancelled<br>    &amp;&amp; payment.State == PaymentState.Declined<br>    &amp;&amp; now &gt;= cycle.DueUtc.AddHours(6))<br>{<br>    await context.CallActivityAsync(<br>        &quot;SendPaymentReminder&quot;,<br>        new PaymentReminder(cycle, $&quot;{cycle.CycleId}:reminder-1&quot;));<br>    reminderSent = true;<br>}<br><br>now = context.CurrentUtcDateTime;<br>if (now &gt;= cycle.GraceUntilUtc)<br>    break;<br><br>DateTime wakeAt = now.AddHours(6);<br>if (wakeAt &gt; cycle.GraceUntilUtc)<br>    wakeAt = cycle.GraceUntilUtc;<br><br>try<br>{<br>    BillingHint hint = await context.WaitForExternalEvent&lt;BillingHint&gt;(<br>        $&quot;billing:{cycle.CycleId}:v{cycle.EventSchema}&quot;, wakeAt - now);<br><br>    if (hint.CycleId != cycle.CycleId<br>        || hint.EventSchema != cycle.EventSchema<br>        || string.IsNullOrWhiteSpace(hint.EventId))<br>        throw new InvalidOperationException(&quot;Billing event identity mismatch.&quot;);<br>}<br>catch (OperationCanceledException)<br>{<br>    // Timer elapsed. Query the billing ledger.<br>}<br><br>payment = await context.CallActivityAsync&lt;PaymentSnapshot&gt;(<br>    &quot;ReadCyclePayment&quot;, cycle);</pre><p>This excerpt assumes the surrounding orchestrator has validated the cycle, UTC deadlines, grace interval, snapshot identity, and terminal paid/cancellation branches. It also bounds the loop to at most 16 inspected snapshots. Callback handlers authenticate, deduplicate, and persist events in the billing inbox/ledger before sending a hint. Neither a callback nor a payment-method update proves that a charge succeeded.</p><p>When a paid receipt is validated, an idempotent activity closes the paid cycle and obtains the authoritative next cycle. Only then does the orchestrator call context.ContinueAsNew(next, preserveUnprocessedEvents: false) with that next-cycle input. That preserves the instance identity while starting a fresh execution history. Here we deliberately discard buffered old hints because the billing facts were persisted before notification; old settlements remain tied to their original billing cycle.</p><p>If the grace or check budget expires with an unresolved payment, the application outcome is NeedsReconciliation, tracked by a durable recovery queue. Runtime completion isn&#39;t proof of payment. Cancellation stops future collection when recorded but doesn&#39;t erase an already accepted charge.</p><p>I like being able to read the payment recovery policy as one flow, with its due date, callbacks, reminders, and transition to the next cycle. But the billing ledger still decides whether a payment actually happened. The workflow does not get to guess, charge again, or replace the existing payment safeguards. And if one scheduled job already handles this without much ceremony, I would keep that instead.</p><h3>4. Webhook bursts: an example where durable state may not be enough</h3><p>This example is a little different from the previous ones because it is also a good example of where Durable Task does not solve the whole problem.</p><p>Imagine a breaking-news publisher. Editors keep updating a developing story as new information arrives, and readers expect the publisher’s search results to show the latest published version. Every time an editor publishes a change, the content-management system (CMS) emits a webhook. Our service receives it and updates a separate search index.</p><p>For example, an article titled <em>Major storm hits the coast</em> has three published revisions:</p><ul><li><strong>Revision 11:</strong> the initial report.</li><li><strong>Revision 12:</strong> updated casualty figures and evacuation information.</li><li><strong>Revision 13:</strong> a corrected headline and the latest official statement.</li></ul><p>The webhook for revision 12 arrives before the delayed notification for 11; another webhook for 12 arrives twice. While our search index is still processing revision 12, revision 13 is published. Worse, two indexing operations can finish in the opposite order from when they started. Without protection, a slow revision-12 update could overwrite revision 13, leaving readers with stale breaking-news results.</p><p>Here’s the flow. The arrows show <em>possible</em> timing, not a promise of ordering:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/840/1*HNRkDyUN87UiTsoPVLwa9Q.png" /><figcaption>Multiple controllers use activities to read CMS snapshots and write the search index; the index rejects a stale revision.</figcaption></figure><p><strong>The key distinction:</strong> the durable entity remembers the newest revision we’ve <em>heard about</em>. It does not lock the search index or guarantee that only one indexing activity runs. The search destination must atomically reject an older revision if a newer one has already been committed. In the diagram, the delayed revision-12 operation is rejected rather than overwriting revision 13. A separate reliable inbox and follow-up dispatcher ensure that newly published revisions aren’t lost between checks.</p><p>For this example, assume the CMS supplies strictly increasing positive revision numbers per article generation, immutable content for each published revision, and versioned tombstones for deletion. A recreated article gets a new generation. If the CMS supplies only opaque ETags or webhook arrival timestamps, this particular revision-ordering algorithm does not apply.</p><p>A conventional handler can already be correct:</p><pre>public static async Task HandleAsync(<br>    DocumentNotice notice, IDocumentProjection projection,<br>    IProjectionCheckpointStore checkpoints, CancellationToken ct)<br>{<br>    long applied = await checkpoints.ReadAppliedAsync(<br>        notice.DocumentKey, ct);<br>    if (notice.Revision &lt;= applied)<br>        return;<br><br>    DocumentSnapshot snapshot =<br>        await projection.ReadDocumentSnapshotAsync(<br>            new DocumentRead(notice.DocumentKey, notice.Revision), ct);<br><br>    ProjectionReceipt receipt =<br>        await projection.ApplyDocumentRevisionAsync(snapshot, ct);<br><br>    await checkpoints.AdvanceAppliedAsync(receipt, ct);<br>}</pre><p>This assumes the destination atomically rejects stale revisions and handles duplicates idempotently, while the checkpoint advances monotonically. A lost acknowledgement can be reconciled against the destination. Add a reliable inbox/dispatcher and periodic source reconciliation, and you may already have everything you need.</p><p>What does a durable entity change? It can persist the highest observed and applied revisions and serialize <strong>updates to those counters</strong>:</p><pre>public sealed class DocumentRevision : TaskEntity&lt;DocumentRevisionState&gt;<br>{<br>    public void Observe(long revision)<br>    {<br>        if (revision &lt;= 0)<br>            throw new ArgumentOutOfRangeException(nameof(revision));<br>        State.LastObserved = Math.Max(State.LastObserved, revision);<br>    }<br><br>    public RevisionProgress Read() =&gt;<br>        new(State.LastObserved, State.LastApplied);<br><br>    public void ConfirmApplied(ProjectionReceipt receipt)<br>    {<br>        if (receipt.DocumentKey != Context.Id.Key<br>            || receipt.ConfirmedRevision &lt;= 0<br>            || string.IsNullOrWhiteSpace(receipt.ReceiptReference))<br>            throw new InvalidOperationException(&quot;Invalid projection receipt.&quot;);<br><br>        State.LastApplied = Math.Max(<br>            State.LastApplied, receipt.ConfirmedRevision);<br>    }<br>}</pre><p>A separate SynchronizeDocument orchestration calls Observe, waits two seconds with a durable timer, reads the entity, fetches an authoritative immutable snapshot through a read activity, applies it through a write activity, and calls ConfirmApplied only after obtaining a valid external receipt. It can make up to four passes, then request follow-up if work remains.</p><blockquote><strong><em>Two different identities, two different guarantees.</em></strong><em> A durable entity is addressed by its </em><strong><em>entity name and key</em></strong><em>. In this example, </em><em>DocumentRevision plus the article&#39;s generation-qualified key identifies one entity. Durable Task processes operations for that </em>same entity instance<em> serially, so simultaneous </em><em>Observe and </em><em>ConfirmApplied calls cannot corrupt its counters. This does </em><strong><em>not</em></strong><em> mean Durable Task automatically runs only one </em><em>SynchronizeDocument orchestration per article: by default, separately scheduled orchestrations have separate instance IDs. See </em><a href="https://learn.microsoft.com/en-us/azure/durable-task/common/durable-task-entities"><em>Microsoft: Durable entities</em></a><em> and </em><a href="https://learn.microsoft.com/en-us/azure/durable-task/common/durable-task-orchestrations"><em>orchestration instance identity</em></a><em>.</em></blockquote><p>If we want <strong>one active synchronization controller per article</strong>, we can deliberately adopt the <a href="https://learn.microsoft.com/en-us/azure/durable-task/common/durable-task-singletons">singleton orchestrator pattern</a>: assign a stable orchestration instance ID derived from the article’s generation-qualified key, rather than creating a new instance for each webhook. The inbox records every notice; the entity keeps the highest observed revision; the dispatcher starts the controller if needed or signals the existing controller to check again. The inbox must also cover the race between a controller’s final read and its completion. A check-then-start request can itself race, so the dispatcher must handle duplicate-start outcomes and reconcile admission rather than assume both callers’ success responses mean two controllers started.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/840/1*fLWnVqC9yy1bODhVDKGsAw.png" /><figcaption>A dispatcher reconciles stable controller admission while activities read the CMS and write to a revision-protected search index.</figcaption></figure><p>A singleton controller avoids <em>concurrent controllers</em> for the same article when admission is implemented correctly, but it is <strong>not</strong> an exactly-once guarantee for external effects. A timed-out or redelivered activity may have left an indexing request running, and an earlier execution may have submitted work before it stopped. Keep the destination’s atomic revision check and reconcile uncertain writes even with a singleton controller. The sequence diagram above intentionally depicts the <strong>multiple-controller variant</strong>, to show the race that the entity’s serialization alone does not prevent.</p><p>But here’s the catch: <strong>serializing entity operations does not serialize the external index writes</strong>. Two orchestrations can both read LastObserved = 13 and LastApplied = 12, then fetch and attempt revision 13 concurrently. The two-second pause may even align them. This design does not establish fewer fetches, fewer writes, or true debounce behavior.</p><p>Its correctness still depends on a destination-enforced atomic generation/revision fence. A delayed revision-12 write cannot overwrite an already committed revision 13. If the destination cannot enforce that rule, introduce a separately enforced single writer or fenced-writer protocol instead. The entity alone is insufficient.</p><p>A final read also races with a newly arriving revision. The application inbox/outbox must durably admit every validated notice and dispatch follow-up work; periodic reconciliation handles missing webhooks. Resetting an entity cannot reset the destination’s revision protection.</p><p>The entity gives us a durable place to remember the latest article revision, and its operations are serialized for that entity key. That is useful, but it does not magically serialize the external indexing requests. If our existing checkpoint store and dispatcher already coordinate revisions reliably, adding an entity might not buy us much. I would measure actual indexing calls before claiming this approach reduces work.</p><h3>5. An AI-assisted incident workflow: preserving the exact decision across a crash</h3><p>I have written before about <a href="https://www.tamirdresher.com/blog/2026/05/21/deterministic-meets-squads">this kind of AI workflow and why deterministic orchestration matters</a> when models and tools are involved. In that post, I focused mostly on making the workflow predictable. I did not spend nearly enough time on what happens when the process disappears halfway through. That is what I want to explore here.</p><p>Consider a production incident assistant: it gathers telemetry, asks a model to propose a remediation, waits for an engineer to approve the <em>specific</em> proposal, runs the approved operation, and verifies the result. The investigation might take minutes; approval might arrive hours later. During that time, the process hosting the assistant may be recycled or deployed.</p><p>We need parallel work, durable waits, replay, and external-operation reconciliation. We also face a new problem: an LLM response is not a deterministic function we should casually recompute after recovery. The next response might recommend a different action, use different tool arguments, or refer to different evidence.</p><h3>Before: an ordinary asynchronous agent pipeline</h3><p>Suppose the application already has services for telemetry, model inference, approval, remediation, and verification. A simplified ordinary implementation might look like this:</p><pre>public static async Task&lt;IncidentReport&gt; InvestigateAsync(<br>    IncidentRequest incident,<br>    IIncidentApplication app,<br>    CancellationToken ct)<br>{<br>    Task&lt;EvidenceRef&gt; logs = app.CollectLogsAsync(incident, ct);<br>    Task&lt;EvidenceRef&gt; metrics = app.CollectMetricsAsync(incident, ct);<br>    await Task.WhenAll(logs, metrics);<br>    EvidenceBundle evidence = new(await logs, await metrics);<br><br>    ProposedRemediation proposal =<br>        await app.GenerateRemediationAsync(incident, evidence, ct);<br>    await app.OpenApprovalAsync(proposal, ct);<br><br>    // A separate callback must resume the right proposal after approval.<br>    return new IncidentReport(incident.IncidentId, &quot;AwaitingApproval&quot;);<br>}</pre><p>This method is not inherently wrong. The application could save a state machine in SQL and continue from an approval callback. But that continuation handler must retrieve the exact evidence and proposal version, check authorization and expiry, and decide whether remediation was already submitted. If the worker crashes after the model generates its proposal but before saving it, generating again is a <em>new</em> decision, not recovery of the same one.</p><p>The crucial design decision is to separate the <strong>nondeterministic generation step</strong> from the <strong>deterministic coordination step</strong>. The model call belongs inside an activity. The orchestrator receives a stable reference to an immutable proposal, not a mutable chat transcript or a promise that the model will answer identically next time.</p><h3>After: durable coordination around an agent</h3><p>For this example, assume the application defines IncidentPlan with a stable incident ID, immutable request generation, approval ID and UTC deadline. EvidenceBundle contains references to saved telemetry, not raw logs. ProposedRemediation contains an immutable proposal ID, evidence references, canonical action parameters, a content fingerprint and policy version. ApprovalDecision names the approved proposal ID and fingerprint. These are <strong>application contracts</strong>, not types supplied by Durable Task.</p><p>The request records below name the payloads passed to activities. ApprovalKind distinguishes Pending, Approved, Rejected, and Expired; only approval can lead to remediation. The activities and remaining DTOs are application code, not a complete executable sample:</p><pre>public sealed record GenerateProposalRequest(<br>    IncidentPlan Plan, EvidenceBundle Evidence);<br>public sealed record RemediationApprovalRequest(<br>    IncidentPlan Plan, ProposedRemediation Proposal);<br>public sealed record VerifyRemediationRequest(<br>    IncidentPlan Plan, RemediationReceipt Receipt);<br><br>public sealed class InvestigateIncident<br>    : TaskOrchestrator&lt;IncidentPlan, IncidentOutcome&gt;<br>{<br>    public override async Task&lt;IncidentOutcome&gt; RunAsync(<br>        TaskOrchestrationContext context, IncidentPlan plan)<br>    {<br>        Task&lt;EvidenceRef&gt; logs = context.CallActivityAsync&lt;EvidenceRef&gt;(<br>            &quot;CollectLogs&quot;, plan);<br>        Task&lt;EvidenceRef&gt; metrics = context.CallActivityAsync&lt;EvidenceRef&gt;(<br>            &quot;CollectMetrics&quot;, plan);<br><br>        await Task.WhenAll(logs, metrics);<br>        EvidenceBundle evidence = new(await logs, await metrics);<br>        ProposedRemediation proposal =<br>            await context.CallActivityAsync&lt;ProposedRemediation&gt;(<br>                &quot;GenerateAndSaveProposal&quot;,<br>                new GenerateProposalRequest(plan, evidence));<br><br>        await context.CallActivityAsync(<br>            &quot;OpenRemediationApproval&quot;,<br>            new RemediationApprovalRequest(plan, proposal));<br><br>        TimeSpan remaining =<br>            plan.ApprovalDeadlineUtc - context.CurrentUtcDateTime;<br>        if (remaining &gt; TimeSpan.Zero)<br>        {<br>            try<br>            {<br>                // This event is only a wake-up hint, not authorization.<br>                ApprovalHint hint =<br>                    await context.WaitForExternalEvent&lt;ApprovalHint&gt;(<br>                        $&quot;incident-approval:{plan.ApprovalId}&quot;, remaining);<br>                if (hint.IncidentId != plan.IncidentId ||<br>                    hint.ApprovalId != plan.ApprovalId)<br>                    throw new InvalidOperationException(<br>                        &quot;Approval identity mismatch.&quot;);<br>            }<br>            catch (OperationCanceledException)<br>            {<br>                // Deadline elapsed. Resolve authoritative approval state.<br>            }<br>        }<br><br>        ApprovalDecision decision =<br>            await context.CallActivityAsync&lt;ApprovalDecision&gt;(<br>                &quot;ResolveApproval&quot;,<br>                new RemediationApprovalRequest(plan, proposal));<br>        if (decision.Kind is ApprovalKind.Rejected or ApprovalKind.Expired)<br>            return new(plan.IncidentId, decision.Kind.ToString());<br>        if (decision.Kind != ApprovalKind.Approved)<br>            throw new InvalidOperationException(<br>                &quot;Approval is unresolved or unrecognized; reconcile the ticket.&quot;);<br><br>        // The activity must independently revalidate authorization,<br>        // proposal fingerprint, current policy and execution eligibility.<br>        RemediationReceipt receipt =<br>            await context.CallActivityAsync&lt;RemediationReceipt&gt;(<br>                &quot;ExecuteApprovedRemediation&quot;,<br>                new ApprovedRemediation(plan, proposal, decision));<br><br>        VerificationResult verification =<br>            await context.CallActivityAsync&lt;VerificationResult&gt;(<br>                &quot;VerifyRemediation&quot;,<br>                new VerifyRemediationRequest(plan, receipt));<br>        return new(plan.IncidentId, verification.Status,<br>            receipt.ReceiptReference);<br>    }<br>}</pre><p>The snippet expresses dependencies, not a security boundary. The authenticated approval endpoint must atomically commit one terminal decision against the immutable proposal ID, fingerprint and deadline <em>before</em> sending its event hint. ResolveApproval reads that committed record or atomically expires a still-pending ticket at its deadline. An unresolved or unrecognized result fails visibly for reconciliation. A hint cannot authorize a tool call, and a late or duplicate hint cannot overwrite a terminal decision. The remediation activity checks the stored decision again and applies any current safety policy before acting. An approved plan that is no longer safe should stop for fresh review, not be executed merely because it appears in orchestration history.</p><p>Notice that GenerateAndSaveProposal must use a stable generation identity and reconcile its own saved result if the model responded but the activity acknowledgement was lost. Durable Task will reuse the activity result <strong>only after that result was recorded in orchestration history</strong>. It cannot guarantee a model is called exactly once in every failure window. Likewise, ExecuteApprovedRemediation needs a stable execution key, an operation journal and a provider-specific read/reconciliation path; blindly retrying a potentially destructive tool is unacceptable.</p><h3>Follow the failure, not just the happy path</h3><p>Consider four points at which the worker might disappear:</p><p>Interruption Safe recovery behavior</p><p>After telemetry collection was recorded</p><p>Replay consumes recorded activity results; it does not collect those results again merely because the worker restarted.</p><p>After model generation but before its activity result was recorded</p><p>The activity uses the stable generation identity to retrieve the saved proposal or reconcile an uncertain generation; it must not silently substitute a new plan for one already presented.</p><p>While waiting for an engineer</p><p>The durable wait resumes after restart. Approval authority remains in the application approval store, and the fixed deadline does not move.</p><p>After remediation succeeded but before its activity completion was recorded</p><p>Redelivery reconciles the same execution key and external receipt. It does not assume the action failed or execute it a second time without evidence.</p><p>A useful test deliberately kills the worker at each of those points. Then inspect orchestration history, the proposal and approval records, and the remediation operation journal. The invariant is not “the orchestrator completed.” It is “only the specifically authorized proposal was executed, and uncertain side effects were reconciled before further action.”</p><h3>So what did we gain?</h3><p>We now have a workflow that can collect evidence, generate a proposal, wait for approval, execute the approved action, and continue after a worker restart. That is already pretty useful. But notice the boundary: we made the workflow around the AI durable. We did not make the model deterministic, and we did not make every bit of state inside an agent durable. The proposal and approval still need stable identities, and an uncertain tool execution still needs reconciliation.</p><p>This is really the foundation of what I want from durable AI systems. It is also why we went further and built <strong>Durable Agents and Durable Workflows as extensions to Microsoft Agent Framework (MAF)</strong>. Instead of having every application build this coordination around its agents, we wanted durability to be part of the way agents and their workflows run.</p><p>That deserves its own article. In the next part, I will use this same scenario to show what changes when durability moves from the outer orchestration into the agent and workflow programming model itself.</p><h3>A final thought</h3><p>The common thread across these examples is simple. Durable Task can remember what completed, what we are waiting for, and where execution should continue. It cannot tell us whether an external payment really happened, whether an Azure operation is still running, or whether an AI generated action is still safe to execute. Our applications still need to answer those questions.</p><p>I would use Durable Task when the code for remembering and resuming a business process starts becoming a system of its own. Not just because I have a few await calls.</p><p><em>Originally published at </em><a href="https://www.tamirdresher.com/blog/2026/09/28/durable-task-real-world"><em>https://www.tamirdresher.com</em></a><em> on September 27, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=980b0098c6e8" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Three Durable Task Capabilities Worth Knowing About]]></title>
            <link>https://medium.com/@tamir_dresher/three-durable-task-capabilities-worth-knowing-about-a44d9e586ba8?source=rss-21fc7aa0950d------2</link>
            <guid isPermaLink="false">https://medium.com/p/a44d9e586ba8</guid>
            <category><![CDATA[durable-task]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[azure]]></category>
            <dc:creator><![CDATA[Tamir Dresher]]></dc:creator>
            <pubDate>Thu, 24 Sep 2026 21:00:46 GMT</pubDate>
            <atom:updated>2026-10-05T06:35:51.554Z</atom:updated>
            <content:encoded><![CDATA[<p>In <a href="https://www.tamirdresher.com/blog/2026/09/21/durable-await-replay">the previous post</a>, we followed an orchestration through its activity awaits and looked at what actually survives: history, not the original worker’s local tasks or suspended method. I ended by promising to explore some of the capabilities people tend to miss in the Durable Task ecosystem.</p><p>Well, I’m not the guy who breaks promises :-)</p><p>Once you understand how a workflow can keep going without its original worker, a few practical questions follow. Can different parts run on different kinds of workers? Can a workflow that is already running move to a newer implementation? And when something fails after several successful steps, can we repair it without starting the whole business operation again?</p><p>I’ll use the standalone <a href="https://github.com/microsoft/durabletask-dotnet/releases/tag/v1.26.0">Durable Task .NET SDK v1.26.0</a> with manual registration, without the optional source generator. The code below is based on public API definitions and samples. These are teaching illustrations, not compiled or executed demonstrations. Real activity implementations and connection values are omitted; the shipping example includes host setup and startup. The clients and workers are assumed to be configured for the intended DTS task hub.</p><h3>Keep the workflow together, split the workers</h3><p>Suppose order validation is inexpensive, but shipping needs a large integration SDK, different compute, or its own release schedule. I don’t particularly want every validation worker carrying the shipping dependencies just because both activities belong to the same workflow.</p><p>The workflow can stay one workflow without making every worker carry every activity.</p><p>The distinction is between <strong>coordination</strong> and <strong>where work runs</strong>. There are three pieces to keep separate. The <strong>registry</strong> maps task names and versions to code a worker can run. <strong>Filters</strong> describe the work that worker asks DTS to send it. An optional <strong>worker version policy</strong> adds acceptance checks. They need to agree: a filter neither registers a missing implementation nor translates its payload.</p><p>Here’s where those pieces live. Solid arrows show interactions between roles; dashed links show configuration used by the worker SDK, not an order of checks. Client and worker roles can share a process. Click to enlarge.</p><p>An orchestrator still requests activities by name. <a href="https://learn.microsoft.com/en-us/azure/durable-task/scheduler/work-item-filtering">Work item filtering</a> lets DTS deliver those work items to eligible workers, without putting worker addresses into the workflow.</p><p>Here is a small, independently defined orchestration:</p><pre>using System.Threading.Tasks;<br>using Microsoft.DurableTask;<br><br>public sealed class OrderRouting : TaskOrchestrator&lt;string, string&gt;<br>{<br>    public override async Task&lt;string&gt; RunAsync(<br>        TaskOrchestrationContext context, string orderId)<br>    {<br>        string validation = await context.CallActivityAsync&lt;string&gt;(<br>            &quot;ValidateOrder&quot;, orderId);<br>        string shipping = await context.CallActivityAsync&lt;string&gt;(<br>            &quot;ShipOrder&quot;, orderId);<br>        return $&quot;{validation}; {shipping}&quot;;<br>    }<br>}</pre><p>The validation activity throws if the order is invalid. Its returned string is informational, not an approval flag. The activity implementations are outside this example.</p><p>The orchestration knows the logical task name ShipOrder, not the shipping worker&#39;s server address. CallActivityAsync creates an action and a local result task, not a direct call to that host or an immediate durable record. DTS later delivers eligible work; the receiving worker finds its registered implementation and reports the result, which the orchestration uses through history. Shipping workers can move or scale without changing server addresses in the workflow. The activity&#39;s own external connections are still ordinary application networking.</p><h3>Configure the worker, then start the host</h3><p>For an ordinary specialist worker, register only its own tasks and let the SDK generate the filters. The shipping project’s Program.cs can look like this. ShipOrderActivity is the project&#39;s existing unversioned TaskActivity&lt;string, string&gt; implementation; register its dependencies in builder.Services as usual before building the host. Set ConnectionStrings:Dts to the shared scheduler and task hub connection value. Its environment variable form is ConnectionStrings__Dts.</p><pre>using System;<br>using Microsoft.DurableTask.Worker;<br>using Microsoft.DurableTask.Worker.AzureManaged;<br>using Microsoft.Extensions.Configuration;<br>using Microsoft.Extensions.DependencyInjection;<br>using Microsoft.Extensions.Hosting;<br><br>HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);<br>string connectionString = builder.Configuration.GetConnectionString(&quot;Dts&quot;)<br>    ?? throw new InvalidOperationException(&quot;Configure ConnectionStrings:Dts.&quot;);<br><br>builder.Services.AddDurableTaskWorker()<br>    // Map the durable activity name to this worker&#39;s local handler.<br>    .AddTasks(registry =&gt;<br>        registry.AddActivity&lt;ShipOrderActivity&gt;(&quot;ShipOrder&quot;))<br>    // Request work for the registered tasks.<br>    .UseWorkItemFilters()<br>    .UseDurableTaskScheduler(connectionString);<br><br>using IHost host = builder.Build();<br>// Start the host and keep the worker running until shutdown.<br>await host.RunAsync();</pre><p>The worker configuration block registers a worker; it does not start one. Build() creates the host, and RunAsync starts it and keeps it running until shutdown.</p><p>ShipOrderActivity still needs to do the real shipping work. Returning a cheerful string is not a shipping integration, however much easier that would make the example.</p><p>The other deployments use the same Program.cs pattern, with their own registrations inside AddTasks:</p><p>The validation row likewise assumes an existing ValidateOrderActivity : TaskActivity&lt;string, string&gt; implementation and its registered dependencies, with the invalid order behavior described above. <strong>Every participating worker explicitly calls </strong><strong>.UseWorkItemFilters()</strong>, connects to the same scheduler and task hub, and starts its host. For this SDK configuration, that <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs">explicit builder call</a> is the filtering setup I rely on, not an assumed default.</p><p>The parameterless call generates filters from the completed registry and worker options for that worker when its options are resolved. It does not freeze a list of whichever registrations happen to appear above the call in the file. Finish configuring the host before building and starting it.</p><h3>What can you select?</h3><p>The <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs">custom filter object</a> is DurableTaskWorkerWorkItemFilters, in Microsoft.DurableTask.Worker. These are <strong>properties on that object</strong>. Each property holds a collection; the entry types are nested inside DurableTaskWorkerWorkItemFilters.</p><p>These next three are <strong>calls on the worker builder</strong> returned by AddDurableTaskWorker(). They are not members of the filter object.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*f7d4H6ZaL3QWjSGG.jpg" /><figcaption>Figure 1A. The client requests an orchestration; the worker requests eligible work and reports outcomes. The registry, filters and optional version policy belong to worker configuration. DTS is shown only through its documented service contract.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*EkYgBn1CPFWvSh-h.jpg" /><figcaption>Figure 1B. No eligible shipping worker means a pending work item, not delivery to the validation worker. The workflow may remain Running while it waits. When shipping work is handled, the result reaches the orchestration through history, not a callback to the old invocation. This is the primary unversioned setup, not the optional custom subset.</figcaption></figure><p>UseWorkItemFilters()</p><p>Generate selections from the completed registry and worker options.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*z7x7YZRBbrPsw2Fi.jpg" /><figcaption>Figure 2. Same instance, new execution, explicit input data. Optional input fields are our handoff contract, not inherited activity results. Unprocessed external events may also be preserved separately. A compatible backend and an eligible registered v2 implementation are required; arrows show logical data flow, not measured execution.</figcaption></figure><p>UseWorkItemFilters(filters)</p><p>Use the supplied custom object instead of generating selections. This configures whole collections, not an incremental filter addition.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*uVOBplpC6sY_2ZI8.jpg" /><figcaption>Figure 3. Repair first, then request rewind. Successful recorded results support reconstruction; failed loading work may run again. This is a conceptual successful scheduling path for an eligible instance, not an executed failure test or a guarantee that the next load attempt succeeds.</figcaption></figure><p>UseWorkItemFilters(null)</p><p>Disable filtering. This does not pause the worker or mean “receive nothing.”</p><p>You can select multiple names and concrete versions for orchestration and activity work. An entity filter has no version property. This is not a predicate engine for payloads, tenants, instance IDs, hardware, or priorities. There is no documented name glob, regex, or prefix syntax here: use the actual registered name, not Ship*.</p><p>Creating new DurableTaskWorkerWorkItemFilters() without populating Orchestrations, Activities or Entities leaves all three collections empty. That is not a documented way to request no work, so don&#39;t rely on it as a pause switch. To stop processing, stop the worker. The .UseWorkItemFilters(null) method call above explicitly disables filtering; it is not a pause switch either.</p><h3>Optionally advertise less than you register</h3><p>Sometimes the code a worker contains and the work it should receive are intentionally different. Here, the registry contains ShipOrder and CancelShipment, both at version &quot;1&quot;, but the filter advertises only ShipOrder version &quot;1&quot;.</p><p>The optional example below replaces the worker configuration block in Program.cs: the block that starts with builder.Services.AddDurableTaskWorker() and ends with .UseDurableTaskScheduler(connectionString). Put ShippingSubset.Configure(builder.Services, connectionString, shipOrder, cancelShipment); in that position, after reading the connection string and before builder.Build(). The application supplies shipOrder and cancelShipment as its existing activity instances, with the types shown below. Keep the configuration reading, Build() and await host.RunAsync() in place; do not append a second default worker registration.</p><pre>using Microsoft.DurableTask;<br>using Microsoft.DurableTask.Worker;<br>using Microsoft.DurableTask.Worker.AzureManaged;<br>using Microsoft.Extensions.DependencyInjection;<br><br>public static class ShippingSubset<br>{<br>    public static void Configure(<br>        IServiceCollection services,<br>        string connectionString,<br>        TaskActivity&lt;string, string&gt; shipOrder,<br>        TaskActivity&lt;string, string&gt; cancelShipment)<br>    {<br>        var filters = new DurableTaskWorkerWorkItemFilters<br>        {<br>            Activities = new[]<br>            {<br>                new DurableTaskWorkerWorkItemFilters.ActivityFilter(<br>                    &quot;ShipOrder&quot;, new[] { &quot;1&quot; }),<br>            },<br>        };<br><br>        services.AddDurableTaskWorker()<br>            .AddTasks(registry =&gt;<br>            {<br>                registry.AddActivity(<br>                    &quot;ShipOrder&quot;, new TaskVersion(&quot;1&quot;), () =&gt; shipOrder);<br>                registry.AddActivity(<br>                    &quot;CancelShipment&quot;, new TaskVersion(&quot;1&quot;),<br>                    () =&gt; cancelShipment);<br>            })<br>            .UseWorkItemFilters(filters)<br>            .UseDurableTaskScheduler(connectionString);<br>    }<br>}</pre><p>The <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/DurableTaskRegistry.Activities.cs">versioned registrations</a> supply implementations; the ActivityFilter supplies a name and version selection. This worker can instantiate both activities, but asks to receive only the advertised one. If the workflow needs CancelShipment, an eligible worker elsewhere must serve it.</p><p>The caller also needs to request the version you advertised. For this optional example, use this separate orchestration rather than assuming the earlier unversioned OrderRouting call selects version 1:</p><pre>using System.Threading.Tasks;<br>using Microsoft.DurableTask;<br><br>public sealed class ShippingRequest : TaskOrchestrator&lt;string, string&gt;<br>{<br>    public override Task&lt;string&gt; RunAsync(<br>        TaskOrchestrationContext context, string orderId)<br>        =&gt; context.CallActivityAsync&lt;string&gt;(<br>            &quot;ShipOrder&quot;, orderId,<br>            options: new TaskOptions<br>            {<br>                Version = new TaskVersion(&quot;1&quot;),<br>            });<br>}</pre><p>Register ShippingRequest on an orchestration worker with registry.AddOrchestrator&lt;ShippingRequest&gt;(&quot;ShippingRequest&quot;), that worker&#39;s own parameterless filters, and the same task hub and host startup pattern. <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/TaskOptions.cs">TaskOptions.Version</a> here requests activity version &quot;1&quot;; it does not register the activity on the caller.</p><p>Keep the exact name and version values aligned across the call, filter, and implementation. The SDK’s <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs">custom filter validator</a> checks that the selected names are registered in the corresponding work kind. It does <strong>not</strong> check that every advertised version has an implementation. If you also configure UseVersioning(...), its acceptance policy must agree with those selections. A broader advertisement cannot supply missing code.</p><p>For most specialist services, the first pattern is simpler: register only what the role needs and generate its filters. Use the custom subset when that separation is actually useful, not just because another configuration object exists.</p><h3>What happens between this worker and DTS?</h3><p>The public SDK and documented service contract give us the useful sequence without needing to know the scheduler’s internal implementation:</p><ol><li><strong>Configure the host.</strong> Register the local implementations and choose generated or custom filters. In the GrpcDurableTaskWorker constructor, the SDK <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcDurableTaskWorker.cs">reads this worker&#39;s named filter configuration</a>. This single line omits the surrounding constructor; name is the worker configuration name, not an activity name.</li></ol><pre>this.workItemFilters = workItemFiltersMonitor?.Get(name);</pre><ol><li><strong>Request work.</strong> The SDK <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs">includes the configured eligibility in its work request</a>. This excerpt from GrpcDurableTaskWorker.Processor.cs shows the request construction, with the omitted concurrency and capability fields marked:</li></ol><pre>return this.client!.GetWorkItems(<br>    new P.GetWorkItemsRequest<br>    {<br>        // Concurrency and capability fields omitted.<br>        WorkItemFilters = this.worker.workItemFilters?.ToGrpcWorkItemFilters(),<br>    },<br>    cancellationToken: cancellation);</pre><ol><li>This is worker SDK code using the <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Grpc/orchestrator_service.proto">public request contract</a>, not the service’s matching implementation. The worker is asking DTS for work, not the orchestrator calling the shipping host.</li><li><strong>Receive matching work.</strong> DTS delivers work according to the <a href="https://learn.microsoft.com/en-us/azure/durable-task/scheduler/work-item-filtering">documented matching contract</a>. If no eligible worker is available, that work item stays pending.</li><li><strong>Run the registered implementation.</strong> In the activity handling path of GrpcDurableTaskWorker.Processor.cs, the SDK <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs">looks up a registered activity</a> using the name and requested version from the received work. scope.ServiceProvider supplies local dependency injection. Here is the lookup excerpt, with the surrounding handler omitted:</li></ol><pre>bool found = this.worker.Factory is IVersionedTaskFactory versionedFactory<br>    ? versionedFactory.TryCreateActivity(<br>        name,<br>        requestedVersion,<br>        scope.ServiceProvider,<br>        out ITaskActivity? activity)<br>    : this.worker.Factory.TryCreateActivity(name, scope.ServiceProvider, out activity);</pre><ol><li>The final branch supports the other <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Core/DurableTaskFactory.cs">local factory interface</a>; it is not fallback delivery to another worker. Execution happens only when found is true. Configured worker version checks still apply, and the worker reports the activity&#39;s outcome through the normal protocol.</li></ol><p>Configure the registry and filters before starting the host. For a change, rebuild or restart the worker with the new configuration; mutating a running worker’s filter object is not a documented hot update mechanism. Set the intended filters on every worker in a mixed pool. Enabling them only on the shipper does not restrict an unfiltered worker beside it.</p><p>Here’s why that separation matters when a worker is missing. Follow the shipping step of our primary unversioned example, after validation has completed: the validation worker cannot receive ShipOrder, so that work waits until an eligible shipping worker requests it. Its result becomes history for a later orchestration pass. The columns are roles, not required separate machines; arrows show conceptual order, not measured timing. Click to enlarge.</p><p>What happens if the shipping deployment is missing?</p><p>With this filtered setup, the shipping work stays pending. The orchestration can still be Running, waiting for that result. There is no fallback that asks the validation worker to improvise a shipping implementation.</p><p>That makes deployment order important: deploy the handler before callers begin requesting it, keep task names and versions compatible, and monitor the backlog. Worker specialization gives you independent deployment and scaling choices. It does not configure an autoscaler for you.</p><p>One more boundary worth stating: filtering is <strong>routing, not authorization or tenant isolation</strong>. Worker identities and access controls still matter, as does access to whatever external systems the activities use. Sharing a task hub is not permission to skip those checks.</p><h3>Keep the instance, change the next execution</h3><p>Now suppose the workflow lives longer than a release cycle. We have a better implementation, but waiting for every existing instance to finish would be a fairly optimistic deployment plan.</p><p>Do we need to create a different workflow instance and somehow transfer the business identity to it?</p><p>Not necessarily. First, separate two terms. The <strong>instance</strong> is the logical workflow addressed by its instance ID. An <strong>execution</strong> is one generation of that instance. We can keep the instance while deliberately beginning another execution with a new version and a fresh execution history.</p><p>The API for that boundary is <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/ContinueAsNewOptions.cs">ContinueAsNew with </a><a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/ContinueAsNewOptions.cs">ContinueAsNewOptions.NewVersion</a>. It is not an assignment to context.Version, and it is not permission to change arbitrary code halfway through replay.</p><blockquote><strong><em>Version and backend scope.</em></strong><em> The released .NET v1.26.0 SDK exposes this API, and the </em><a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/samples/EternalOrchestrationVersionMigrationSample/README.md"><em>public standalone migration sample</em></a><em> shows the intended routing model. A compatible backend must support the version change, and the standalone worker must forward it. That is not a claim that every backend or every managed DTS deployment has the same feature rollout. Verify support in the environment you intend to use.</em></blockquote><p>The next version can accept a different input shape. Here v1 waits for a count it already computes and puts that value into optional fields on v2’s input. V2 reuses it only when the count still means the same thing for the same immutable batch snapshot. Otherwise it calls its own counting activity. These fields are our chosen handoff, not a way for v2 to reach back into v1’s completed history.</p><p>In this example, v1 already calls CountRecordsV1 and ends with a continuation. We change what it passes at that existing boundary. Both counting activities are existing read only TaskActivity&lt;string, long&gt; implementations supplied by the application.</p><pre>using System;<br>using System.Threading.Tasks;<br>using Microsoft.DurableTask;<br><br>public sealed record BatchInputV1(<br>    string SnapshotReference,<br>    bool MigrationRequested = false);<br><br>public sealed record BatchInputV2(<br>    string SnapshotReference,<br>    long? CarriedRecordCount = null,<br>    string? CountContract = null,<br>    bool MigrationRequested = false);<br><br>public sealed class BatchLoopV1 : TaskOrchestrator&lt;BatchInputV1, long&gt;<br>{<br>    public override async Task&lt;long&gt; RunAsync(<br>        TaskOrchestrationContext context, BatchInputV1 input)<br>    {<br>        if (input.MigrationRequested)<br>        {<br>            throw new NotSupportedException(<br>                &quot;The migration payload returned to v1. Check version support.&quot;);<br>        }<br><br>        // This activity call was already part of the old execution&#39;s sequence.<br>        long recordCount = await context.CallActivityAsync&lt;long&gt;(<br>            &quot;CountRecordsV1&quot;, input.SnapshotReference);<br><br>        context.ContinueAsNew(new ContinueAsNewOptions<br>        {<br>            NewVersion = &quot;2&quot;,<br>            NewInput = new BatchInputV2(<br>                SnapshotReference: input.SnapshotReference,<br>                CarriedRecordCount: recordCount,<br>                CountContract: &quot;record-count/v1&quot;,<br>                MigrationRequested: true),<br>            PreserveUnprocessedEvents = true,<br>        });<br><br>        return recordCount;<br>    }<br>}<br><br>public sealed class BatchLoopV2 : TaskOrchestrator&lt;BatchInputV2, long&gt;<br>{<br>    public override async Task&lt;long&gt; RunAsync(<br>        TaskOrchestrationContext context, BatchInputV2 input)<br>    {<br>        if (input.CarriedRecordCount is long carried<br>            &amp;&amp; carried &gt;= 0<br>            &amp;&amp; input.CountContract == &quot;record-count/v1&quot;)<br>        {<br>            return carried;<br>        }<br><br>        long recordCount = await context.CallActivityAsync&lt;long&gt;(<br>            &quot;CountRecordsV2&quot;, input.SnapshotReference);<br><br>        if (recordCount &lt; 0)<br>        {<br>            throw new InvalidOperationException(<br>                &quot;The counting activity returned an invalid negative count.&quot;);<br>        }<br><br>        return recordCount;<br>    }<br>}</pre><p>long? makes missing different from zero. With the matching contract, 0 is a valid carried count and is reused. A missing or negative count, or a different contract, sends v2 to CountRecordsV2. A negative result from that activity fails instead of being accepted as a count.</p><p>CountContract is an application compatibility rule: the value must describe this same immutable snapshot using our record-count/v1 definition. The tag is not an SDK check or proof that the input is trustworthy. If the meaning of the count, schema, or business rules changes, validate or translate the carried data, or change the accepted contract and recompute. Presence alone is not enough.</p><p>The payload in NewInput must match v2&#39;s serialized input contract. The <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Core/Shims/TaskOrchestrationShim.cs">SDK adapter deserializes the input into the selected implementation&#39;s input type</a>. It is not a CLR cast from BatchInputV1 to BatchInputV2, or an automatic conversion between arbitrary schemas. V1 constructs the new shape, and its fields, value types and serializer settings must agree with v2.</p><p>V2 can take different activity paths because it starts another execution rather than replaying v1’s completed history. Its code must still be compatible with its own history as it accumulates. The old local variables, tasks and continuation do not cross the boundary.</p><p>Both input types retain SnapshotReference and MigrationRequested. The guard catches a migrated payload routed back to v1 only if the payload and marker arrive and the converter can read them as BatchInputV1. A stricter converter can fail before the guard. The <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/samples/EternalOrchestrationVersionMigrationSample/Program.cs">public migration example</a> highlights the backend dependency; this check is not proof of backend or serializer compatibility.</p><h3>Accepting a version is not the same as implementing it</h3><p>We need both the worker’s version acceptance policy and the right registered implementation. A worker accepting older work does not magically contain the older code.</p><p>Here is the configuration inside an existing Generic Host setup. builder is the host builder, and connectionString identifies the same task hub. countRecordsV1 and countRecordsV2 are the application&#39;s existing TaskActivity&lt;string, long&gt; instances, with their real dependencies supplied by the application:</p><pre>using Microsoft.DurableTask;<br>using Microsoft.DurableTask.Client;<br>using Microsoft.DurableTask.Client.AzureManaged;<br>using Microsoft.DurableTask.Worker;<br>using Microsoft.DurableTask.Worker.AzureManaged;<br>using Microsoft.Extensions.DependencyInjection;<br><br>builder.Services.AddDurableTaskWorker(worker =&gt;<br>{<br>    worker.AddTasks(registry =&gt;<br>    {<br>        registry.AddOrchestrator(&quot;BatchLoop&quot;, new TaskVersion(&quot;1&quot;),<br>            () =&gt; new BatchLoopV1());<br>        registry.AddOrchestrator(&quot;BatchLoop&quot;, new TaskVersion(&quot;2&quot;),<br>            () =&gt; new BatchLoopV2());<br>        registry.AddActivity(&quot;CountRecordsV1&quot;, new TaskVersion(&quot;1&quot;),<br>            () =&gt; countRecordsV1);<br>        registry.AddActivity(&quot;CountRecordsV2&quot;, new TaskVersion(&quot;2&quot;),<br>            () =&gt; countRecordsV2);<br>    });<br>    worker.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions<br>    {<br>        Version = &quot;2&quot;,<br>        DefaultVersion = &quot;2&quot;,<br>        MatchStrategy =<br>            DurableTaskWorkerOptions.VersionMatchStrategy.CurrentOrOlder,<br>        FailureStrategy =<br>            DurableTaskWorkerOptions.VersionFailureStrategy.Reject,<br>    });<br>    worker.UseWorkItemFilters();<br>    worker.UseDurableTaskScheduler(connectionString);<br>});<br>builder.Services.AddDurableTaskClient(<br>    clientBuilder =&gt; clientBuilder.UseDurableTaskScheduler(connectionString));</pre><p>Both classes use the same logical name, BatchLoop, with <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/DurableTaskRegistry.Orchestrators.cs">separate version registrations</a>. This worker&#39;s explicit CurrentOrOlder policy permits version 1 work alongside version 2, and the registry supplies both implementations. Those are two separate requirements.</p><p>In this SDK path, an activity call without an explicit TaskOptions.Version inherits the orchestration execution&#39;s version. The two count activities are therefore registered at versions &quot;1&quot; and &quot;2&quot; to match their callers.</p><p>sends mismatched work back for another attempt. It does not upgrade the work or guarantee that an eligible worker exists. Keep support for version 1 while executions still need it.</p><p>After starting the host, an already resolved DurableTaskClient client can schedule the illustrative version 1 instance:</p><pre>string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(<br>    &quot;BatchLoop&quot;,<br>    input: new BatchInputV1(&quot;snapshot-42&quot;),<br>    options: new StartOrchestrationOptions<br>    {<br>        Version = new TaskVersion(&quot;1&quot;),<br>    });</pre><p>The starter’s await returns the instance ID after scheduling, not after the workflow finishes. &quot;snapshot-42&quot; is an illustrative reference to immutable data, not a claimed count. The continuation keeps that instance ID and creates a new execution at version 2, using the BatchInputV2 payload v1 constructed.</p><p>A separate instance can start directly at version &quot;2&quot; with input: new BatchInputV2(&quot;snapshot-42&quot;) and Version = new TaskVersion(&quot;2&quot;) in its start options. With no carried count or contract, it calls CountRecordsV2. That is a direct start, not the migration of the instance above.</p><p>Read the next diagram as data flow between two executions of one instance. Follow the count from the awaited v1 activity into NewInput, then follow v2&#39;s reuse check. The fork is application logic: use a compatible carried value, including zero, or run CountRecordsV2. No completed v1 history or live CLR objects move along the arrows. Click to enlarge.</p><h3>Put the boundary where the workflow already has one</h3><p>V1 already counts the snapshot before reaching its <strong>existing final continuation call</strong>. The migration changes the input it carries across that boundary and the requested next version. Preserve the earlier durable calls, their ordering, names, versions, inputs and compatible serialization behavior. Do not insert new work into an earlier replay just because v2 will use a different flow.</p><p>Finish and await work whose results matter before continuing. Outstanding operation results can be discarded at the boundary. That does not mean an activity already affecting an external system has been cancelled or rolled back.</p><p>PreserveUnprocessedEvents = true is explicit here and is also the default for this .NET option. It preserves unprocessed external events for the next execution; false discards them. The new code must understand both the carried input and any preserved event payloads. Mapping NewInput does not convert those event payloads. Fresh history does not mean an empty workflow with no starting records, and it does not promise that the service purges every diagnostic record.</p><p>Finally, return recordCount immediately after ContinueAsNew exits v1&#39;s method. On the supported continuation path, the continuation action and NewInput, not this return value, define the next execution. V1 does not call v2 directly or wait for it to finish. V2&#39;s later return completes this finite example.</p><p>This is an action from a running orchestration, not an operator command for reviving a failed one. Putting it in finally is not a reliable plan for recovering an uncaught failure.</p><p>Which brings us to the third capability.</p><h3>Repair a failed instance without rerunning every successful step</h3><p>Consider an import workflow: extract a batch, transform it, then load it into a destination. Extraction and transformation succeed and their results are recorded. Loading then fails because of a configuration problem.</p><p>You fix the configuration. Do you have to request the whole import again?</p><p>For an eligible failed instance, <a href="https://learn.microsoft.com/en-us/azure/durable-task/common/durable-task-instance-management#rewind-orchestration-instances">rewind</a> provides a more targeted recovery path. It is an explicit operator request after a failure, not ordinary replay, not a retry policy, and not a version migration.</p><p>Rewind is more than setting Failed back to Running. The <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Client/Core/DurableTaskClient.cs">.NET client contract</a> describes a new execution ID for the same instance, with recovery history that lets failed work run again. Compatible orchestration code runs against that history; its old stack is not resumed.</p><p>Here is the workflow we’ll follow. The activity results are references to the extracted and transformed data, not large datasets carried through the example:</p><pre>using System.Threading.Tasks;<br>using Microsoft.DurableTask;<br><br>public sealed record LoadRequest(string BatchId, string ManifestReference);<br><br>public sealed class ImportBatch : TaskOrchestrator&lt;string, string&gt;<br>{<br>    public override async Task&lt;string&gt; RunAsync(<br>        TaskOrchestrationContext context, string batchId)<br>    {<br>        string extracted = await context.CallActivityAsync&lt;string&gt;(<br>            &quot;ExtractBatch&quot;, batchId);<br>        string transformed = await context.CallActivityAsync&lt;string&gt;(<br>            &quot;TransformBatch&quot;, extracted);<br>        return await context.CallActivityAsync&lt;string&gt;(<br>            &quot;LoadBatch&quot;, new LoadRequest(batchId, transformed));<br>    }<br>}</pre><p>Register ImportBatch and the named activity implementations on compatible workers in the same hub. Their actual I/O is omitted. This illustration uses <strong>no durable timers and no retry policy that creates durable timers</strong>: the current management documentation excludes orchestrations that use durable timers from rewind.</p><p>Suppose the loading failure makes this instance Failed. Before requesting recovery, fix the cause <strong>and reconcile any partial writes</strong>. The loader may have changed the destination before reporting failure. The destination has not agreed to forget that just because we found a useful SDK method.</p><p>For this import, recovery looks like this:</p><ol><li>ExtractBatch&#39;s successful recorded data reference is reused by replay, without another extraction.</li><li>TransformBatch&#39;s successful recorded manifest reference is reused, without another transformation.</li><li>The failed LoadBatch work can be attempted again. Partial destination writes are still there unless the application has reconciled or compensated for them.</li></ol><p>The SDK describes replacement history excluding failed activities and sub orchestrations, not clearing every successful result. Relevant failed sub orchestrations can be rewound recursively, and new records are appended as work runs again. This describes the recovery history contract, not physical database deletion or audit retention.</p><p>The caller chooses which eligible failed instance to recover, when to request it, and a diagnostic reason. RewindInstanceAsync has no parameter for selecting activities, an event range, an arbitrary checkpoint, replacement input or output, or individual successful results to discard. It is not a history editing API.</p><p>An administrative application or recovery automation can use this helper:</p><pre>using System;<br>using System.Threading;<br>using System.Threading.Tasks;<br>using Microsoft.DurableTask.Client;<br><br>public static class ImportRecovery<br>{<br>    public static async Task RequestRewindAsync(<br>        DurableTaskClient client,<br>        string instanceId,<br>        string reason,<br>        CancellationToken cancellation = default)<br>    {<br>        OrchestrationMetadata? instance = await client.GetInstanceAsync(<br>            instanceId, getInputsAndOutputs: false,<br>            cancellation: cancellation);<br><br>        if (instance?.RuntimeStatus != OrchestrationRuntimeStatus.Failed)<br>        {<br>            throw new InvalidOperationException(<br>                &quot;Rewind requires an existing Failed instance.&quot;);<br>        }<br><br>        await client.RewindInstanceAsync(<br>            instanceId, reason, cancellation);<br>    }<br>}</pre><p>reason should describe the repair or reconciliation that actually happened. The state check is a useful preflight, not an atomic lock: another operator can act between the read and the request. The backend still validates eligibility. Rewind is for Failed instances, not Running, Pending, Terminated, or Completed ones.</p><p>That does not restore a saved instruction pointer, and it does not automatically change the workflow’s version. Keep the earlier action sequence replay compatible, and keep the referenced data and serialization contracts available.</p><p>The next diagram separates the failed execution from the operator’s repair and rewind request. The initial orchestration passes are condensed; the recorded successes are shown so it is clear what the later execution can reuse. There is no arrow that undoes the destination’s writes. Click to enlarge.</p><p>The important operational detail is what the management await means. <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Client/Core/DurableTaskClient.cs">RewindInstanceAsync</a> completes when the request is enqueued, not when the import is finished. Query the instance afterward. Cancelling that call cancels enqueueing; it does not undo a rewind that was already accepted. If the request&#39;s outcome is uncertain, inspect the instance rather than blindly repeating the operator action.</p><p>This example is scoped to the public .NET API and a backend that supports rewind. Other clients, hosts, or providers should not be assumed to have identical support just because they share a backend name. An unsupported backend can reject the operation.</p><p>And please keep a business key for the loading operation. BatchId gives the destination something stable to deduplicate on, <strong>if the integration actually implements that contract</strong>. Merely passing the field does not create idempotency. An execution ID is a poor replacement here because rewind creates another execution.</p><p>Fix what broke, reconcile what may already have happened, then ask the failed instance to continue. Rewind is not external rollback, and a new attempt still has to handle its effects correctly.</p><h3>The workflow is not the deployment</h3><p>These capabilities solve different problems. Filtering chooses which workers can execute work. A continuation boundary deliberately moves the same instance into another execution and implementation. Rewind lets an operator request recovery after fixing an eligible failure, using the successful history that is still useful.</p><p>None of them removes the need for compatible code and data contracts, or careful handling of external effects. They give us better ways to manage those boundaries without building another workflow coordinator around the workflow coordinator.</p><p>That is what I meant by hidden gems. Once history and actions stop looking like magic, the interesting part is what they let us change safely around a running system.</p><p>Next, I’ll look at AI agents calling models and tools, and why durability matters when several agents coordinate their work. We’ll look at keeping progress and handling repeated effects in that setting too, with multiagent systems like Squad in mind :-)</p><p><em>Originally published at </em><a href="https://www.tamirdresher.com/blog/2026/09/25/durable-task-hidden-capabilities"><em>https://www.tamirdresher.com</em></a><em> on September 24, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a44d9e586ba8" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[What Actually Happens When a Durable Orchestration Awaits an Activity?]]></title>
            <link>https://medium.com/@tamir_dresher/what-actually-happens-when-a-durable-orchestration-awaits-an-activity-111982be84b0?source=rss-21fc7aa0950d------2</link>
            <guid isPermaLink="false">https://medium.com/p/111982be84b0</guid>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[dotnet]]></category>
            <category><![CDATA[durable-task]]></category>
            <category><![CDATA[azure]]></category>
            <category><![CDATA[software-architecture]]></category>
            <dc:creator><![CDATA[Tamir Dresher]]></dc:creator>
            <pubDate>Sun, 20 Sep 2026 21:00:09 GMT</pubDate>
            <atom:updated>2026-10-05T05:46:50.910Z</atom:updated>
            <content:encoded><![CDATA[<p>I’ve recently joined the Durable Task team under Azure Serverless, and I’m very excited. Durable tasks are such an interesting mix of complexity and elegance.</p><p>I’ve written about Durable Task and Durable Task Scheduler (DTS) before, including my <a href="https://www.tamirdresher.com/blog/2026/04/07/durable-task-scheduler">DTS introduction</a>. It still surprises me how many developers don’t really know them, let alone use them. They tackle failure and recovery problems every distributed system has to deal with. Yes, even yours with one microservice and a database.</p><p>Plenty of us can quote the fallacies of distributed computing. Quoting them, though, isn’t the same as handling the failures they describe in our code. Knowing that the network can fail doesn’t tell a workflow halfway through an order what to do when it does. Durable Task helps recover workflow progress. It doesn’t solve every problem in a distributed system.</p><p>I find the Durable Task Framework fascinating, but I think the apparent “magic” also puts people off. How can an orchestration make progress after its original execution in memory is gone? In this post, I want to go under the hood and show you the secret sauce.</p><p>And we can actually look. The <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/README.md">Durable Task Framework/Core</a> and <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/README.md">modern .NET SDK</a> are open source. We’ll follow their worker implementation, not the private internals of the managed DTS backend.</p><p>I’ll follow the <strong>standalone .NET gRPC worker in Durable Task SDK </strong><a href="https://github.com/microsoft/durabletask-dotnet/releases/tag/v1.26.0"><strong>v1.26.0</strong></a>, which <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/Directory.Packages.props#L40-L44">depends on DurableTask.Core 3.9.0</a>. That’s the implementation path for this walkthrough, not a claim about every deployed package combination or Azure Functions configuration.</p><h3>A small orchestration to follow</h3><p>Here is the complete orchestration declaration we’ll follow, based on the public source. [DurableTask] marks the class for the optional generator described below. Activity implementations and registration are omitted; this is a teaching example, not an executed sample.</p><pre>using System.Threading.Tasks;<br>using Microsoft.DurableTask;<br><br>[DurableTask(nameof(OrderFlow))]<br>public sealed class OrderFlow : TaskOrchestrator&lt;string, string&gt;<br>{<br>    public override async Task&lt;string&gt; RunAsync(TaskOrchestrationContext context, string input)<br>    {<br>        /* L1 */ string orderId = input;<br>        /* L2 */ decimal subtotal = await context.CallActivityAsync&lt;decimal&gt;(&quot;ReadSubtotal&quot;, orderId);<br>        /* L3 */ decimal total = subtotal + 10m;<br>        /* L4 */ string receipt = await context.CallActivityAsync&lt;string&gt;(&quot;ChargeOrder&quot;, new { orderId, total });<br>        /* L5 */ return receipt;<br>    }<br>}</pre><p>If you’d rather avoid strings, the optional <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Generators/README.md">Microsoft.DurableTask.Generators package</a> generates strongly typed helpers for activities and orchestrations defined as classes, including calling sub orchestrations and starting orchestrations. The <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/README.md">SDK&#39;s typed example</a> uses CallSayHelloTypedAsync and ScheduleNewHelloCitiesTypedInstanceAsync. That example uses Functions, but the generator also supports standalone workers.</p><p>The <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Generators/DurableTaskSourceGenerator.cs#L870-L1038">generated methods</a> still delegate to CallActivityAsync, CallSubOrchestratorAsync, or ScheduleNewOrchestrationInstanceAsync. They add convenience and type safety at compile time, not a different replay or history model. The generator is <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Generators/Generators.csproj#L20-L25">versioned separately and marked as preview in the code we&#39;re following</a>; that is not a claim about tested package compatibility or the latest release. I&#39;m keeping the strings here so we can see the scheduling identities.</p><p>For the walkthrough, the input is &quot;order-42&quot;, ReadSubtotal produces 100m, and ChargeOrder produces &quot;receipt-7&quot; after receiving a total of 110m. Those values are illustrative, not measured results.</p><p>The extra 10m gives us a local calculation to track. When a later invocation reaches L3, does it recover a saved total variable, or calculate the value again?</p><p>It calculates it again. Let’s see why.</p><h3>Who calls RunAsync, and what happens at await?</h3><p>Before L1 can run, some application code has to request an orchestration instance. Here, client is an already configured DurableTaskClient, connected to the same DTS task hub as a running worker. That worker has OrderFlow, ReadSubtotal, and ChargeOrder registered.</p><pre>string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(<br>    nameof(OrderFlow), input: &quot;order-42&quot;);</pre><p>The <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Client/Core/DurableTaskClient.cs">await in this starter waits for successful scheduling</a> and returns the instance ID. It does not wait for the order to finish, and it does not call RunAsync on the starter&#39;s call stack. The [DurableTask] attribute on our class is a generator marker, not something that starts the method.</p><p><strong>DTS schedules the work. Your worker runs the C#.</strong> The worker receives orchestration work and the available history from DTS. It <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs#L840-L869">looks up the registered orchestrator</a> to find the code for OrderFlow. The starter and worker can be hosted in the same process; these are different roles, not a requirement for separate machines.</p><p>Inside the worker, a library runner reads the available history and drives the orchestration forward. Its .NET name is TaskOrchestrationExecutor. This is library code inside the worker, not DTS itself or a class you have to write. When it processes the starting ExecutionStarted record in the history, <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Core/Shims/TaskOrchestrationShim.cs#L59-L85">the SDK adapter deserializes the input and calls </a><a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Core/Shims/TaskOrchestrationShim.cs#L59-L85">RunAsync</a> with the context and &quot;order-42&quot;. Now L1 runs.</p><p>The runner takes the recorded history and runs or replays our code as far as that history allows, collecting instructions for what should happen next. It returns the remaining instructions to the worker to send to DTS.</p><p>Core calls these instructions <strong>actions</strong> (the orchestration’s decisions about what should happen next) and returns them in OrchestratorExecutionResult.Actions.</p><p>The runner has just read a record saying this instance started and used it to enter RunAsync. Records like this are <strong>history events</strong>. They are the incoming history the runner uses to reconstruct progress, not C# events that application code subscribes to.</p><p>When RunAsync requests ReadSubtotal for &quot;order-42&quot;, the call adds a request to the orchestration context. This local action describes work to ask for next. Creating it locally does not durably record the request. When a new activity is scheduled, a history event records that fact; another records its successful result, 100.</p><p>RunAsync itself returns the receipt, not those actions. The runner executes or reconstructs the method against the incoming history and returns a result object containing the actions still outstanding. During replay, matching recorded schedules removes candidate actions, so they are not sent again.</p><p>At L2, CallActivityAsync <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L126-L148">creates a scheduling instruction and a local result task</a>. The instruction, called an action, says: ask DTS to run ReadSubtotal with &quot;order-42&quot; as input. The ordinary .NET task is what L2 awaits to get the subtotal. Both exist in worker memory. Creating them does not mean the activity has been dispatched or that DTS has durably recorded the request.</p><p>If that result task is incomplete, <a href="https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/await">ordinary C# </a><a href="https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/await">await yields without blocking its thread</a>. This pauses the method, not the runner&#39;s history processing. An already completed task need not make the method pause.</p><p>The runner keeps reading any available history. A completion event later in this same pass can supply a result and let the method continue. A previously recorded schedule can be <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L327-L358">matched to the reconstructed instruction</a> instead of sent again.</p><p>After processing the pass’s available history, the runner <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationExecutor.cs#L138-L215">returns the remaining actions in </a><a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationExecutor.cs#L138-L215">OrchestratorExecutionResult.Actions</a>, and the worker <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs#L892-L902">sends them to DTS</a>. For new activity work, those actions request scheduling. Finishing this response is not finishing the order workflow. One pass can return several actions; an await is not itself a durable checkpoint.</p><p>Here is the actual return statement from Durable Task Core’s <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationExecutor.cs#L138-L215">TaskOrchestrationExecutor.ExecuteCore</a>, with the surrounding method omitted. The Actions property contains the remaining actions collected in the orchestration context:</p><pre>return new OrchestratorExecutionResult<br>{<br>    Actions = this.context.OrchestratorActions,<br>    CustomStatus = this.taskOrchestration.GetStatus(),<br>};</pre><p>Once a new activity is scheduled, <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs#L997-L1038">a separate activity handler in a worker runs it</a> and <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs#L1104-L1119">reports its outcome</a>. A recorded outcome can then be supplied as history for another orchestration pass. In the reconstruction path below, RunAsync starts again and history resolves the new local task at L2. The activity is not calling back into the old suspended method.</p><p>Here’s the same flow as a conventional sequence diagram, read from top to bottom. The client gets an instance ID, the first worker pass asks for ReadSubtotal, and its recorded result lets a later pass recompute 110m and ask for ChargeOrder.</p><p>The columns are roles, not necessarily separate processes. Activation bars on the orchestration worker’s lifeline mark separate passes; a later pass may use the same worker process or a different one. Solid arrows show messages or calls, and dashed arrows show replies. This is a conceptual sequence for the reconstruction path, not a network capture or timing trace. The result reaches the later pass through history, not through a callback to the original waiting task.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pP8KfWtQVfQssC6enFUtIg.png" /><figcaption>Sequence diagram with client, DTS, orchestration worker, and activity handler lifelines. The client receives an instance ID; two orchestration activations schedule ReadSubtotal, use its recorded result 100, recompute 110, and request ChargeOrder.</figcaption></figure><h3>What survives is history, not the suspended method</h3><p><a href="https://learn.microsoft.com/en-us/azure/durable-task/scheduler/durable-task-scheduler">DTS keeps each instance’s history as part of the task hub’s managed state</a>, and a worker receives those records to reconstruct execution. We are describing that contract, not the service’s database or storage layout.</p><p>For ReadSubtotal, two records in our example say:</p><ol><li>Activity <strong>0</strong>, named ReadSubtotal, was scheduled with input &quot;order-42&quot;.</li><li>Activity <strong>0</strong> completed successfully with result <strong>100</strong>.</li></ol><p>These are conceptual descriptions, not an exact data format or the whole history. Starting and housekeeping records are omitted. The <strong>0</strong> identifies the activity, not the record’s position in history.</p><blockquote><strong><em>What about loops?</em></strong><em> Calling the same activity in a </em><em>for or </em><em>foreach is fine if, for the same inputs and recorded history, the loop reproduces the same ordered durable calls. Each call gets </em><a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L126-L148"><em>its own sequence ID from the runtime counter</em></a><em>, and replay recreates those IDs at the same calls. Core </em><a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L327-L358"><em>matches the ID, action type, and activity name</em></a><em>, not the name alone. These IDs are not loop indices or global counters for an activity: timers, sub orchestrations, and event sends share the sequence. Changing iteration order or which durable calls occur can break matching; the same number of calls is not enough.</em></blockquote><p>The first record tells the runner that this activity was already scheduled. During replay, L2 creates a candidate action again. The runner <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L327-L358">matches the record against that action and removes the candidate from its outgoing actions</a>, rather than scheduling ReadSubtotal anew. This record does <strong>not</strong> say the activity finished, and matching it does <strong>not</strong> complete the await. Core represents this scheduling record with the .NET class <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/History/TaskScheduledEvent.cs#L65-L81">TaskScheduledEvent</a>.</p><p>The second record supplies the successful outcome. <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L476-L484">Processing it provides the result to the reconstructed local task</a>, so RunAsync can continue. Core represents this completion record with <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/History/TaskCompletedEvent.cs#L25-L52">TaskCompletedEvent</a>. Its TaskScheduledId is <strong>0</strong> here, connecting the result <strong>100</strong> to the activity that was scheduled.</p><p>For this call, the action <strong>requests</strong> ReadSubtotal, the history events <strong>record</strong> its scheduling and result, and the Task is the local object L2 awaits. Those event classes belong to the runtime&#39;s bookkeeping. Application code calls the orchestration and activity APIs; it normally does not construct history objects or raise C# events. DTS preserves history data, while the worker interprets it using local .NET objects.</p><p>On reconstruction, L2 assigns the recorded result to a fresh subtotal variable as 100m. L3 calculates 110m again. The old variable slots and Task objects are not restored. That does not mean 110m can never appear in history: L4 sends it as part of ChargeOrder &#39;s input. It can be recorded there as serialized input data, not as a snapshot of every local variable or the suspended CLR stack.</p><p>No orchestration thread is parked waiting for the activity. The worker process doesn’t have to stop at every await either. Ending a pass through history isn’t the same as ending a process.</p><h3>Does Worker 1 keep all those waiting tasks in memory?</h3><p>Now, you’ve all heard something I didn’t say. Metaphorically, since you’re reading this. You’re probably thinking:</p><blockquote><em>“Worker 1 ran the first pass. If Worker 2 resumes the orchestration after the activity finishes, what happens to Worker 1 and the </em><em>TaskCompletionSource it created?&quot;</em></blockquote><blockquote><em>“If Worker 1 handles 10,000 orchestration instances, each waiting for an activity that takes a week, does it keep 10,000 pending tasks and state machines in memory for that whole week?”</em></blockquote><p><strong>No. A waiting workflow does not require its original invocation or local tasks to stay alive.</strong> An ordinary async await does not park a thread with an OS stack either. Durable execution lets the workflow make progress without its original continuation in memory.</p><p>Worker 1 requests ReadSubtotal by returning a scheduling action. <a href="https://learn.microsoft.com/en-us/azure/durable-task/common/durable-task-orchestrations#reliability">Once new actions and history have been committed, the runtime can unload that execution</a>. When the activity reports 100 and the result is durably recorded, DTS keeps it in the instance history. The next pass needs that history, not Worker 1&#39;s memory.</p><p>An eligible Worker 2 connected to the same task hub, with compatible code registered, can receive that work. It reruns RunAsync and creates a new local Task and TaskCompletionSource. The TaskScheduled record <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L327-L358">matches the reconstructed request</a>; the TaskCompleted record, through TaskScheduledId, <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L476-L484">supplies </a><a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L476-L484">100 to Worker 2&#39;s new task</a>. Nothing needs to find Worker 1&#39;s original helper or call its old continuation. The durable activity ID is not a .NET Task.Id or a memory address. Switching workers alone does not rerun an activity whose successful result is already recorded.</p><p>The standalone gRPC pass can finish while the workflow waits. It does not require those 10,000 original orchestration task objects to be retained for a week. Once unreferenced, they can be collected. That does not promise immediate garbage collection, zero overhead, free waiting, or scaling to zero. Some hosts cache execution state, but recovery does not depend on the original worker or cache surviving.</p><p>There is one important distinction: <strong>a waiting orchestration is not the same as an activity that keeps running for a week.</strong> An activity executing, awaiting I/O, or sleeping for that long can retain activity worker resources. Its execution is not checkpointed in the middle, and failure can require <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/TaskActivity.cs#L41-L52">another attempt with the usual side effect concerns</a>. If the work is only waiting for time to pass or an outside answer, use a durable timer or wait for an external event instead.</p><h3>Replay, line by line</h3><p>Let’s walk the five lines through three <strong>logical activations</strong>. Here, an activation means one pass in which the worker processes orchestration history and produces actions.</p><p>This simplified trace follows the public source. It is not three measured deliveries or a promise about physical service batches, and housekeeping events are omitted. With no other durable operations in this example, the inspected Core counter assigns activity IDs <strong>0</strong> and <strong>1</strong>. Those IDs correlate actions with scheduled tasks; they are not positions in the event history.</p><p>Read each strip from top to bottom: code position on the left, history cursor on the right, and pending actions, open result tasks, and locals below. A TaskCompletionSource is the helper supplying the awaited task&#39;s result. These are settled teaching checkpoints based on the public source, not a live debugger recording. Click any strip to open the full SVG and enlarge it.</p><p>The runner reads <strong>Earlier history</strong> first to reconstruct this execution. <strong>New events for this step</strong> are the events newly delivered for the current step; “new” does not mean their data is unrecorded. The read counts track events, not unfinished activities, and records already read stay stored.</p><h3>A: ask for the subtotal</h3><p>The starting event supplies the input. L1 reads &quot;order-42&quot;. L2 calls ReadSubtotal, creating action <strong>0</strong> and an open local task. There is no result yet, so the method yields at L2. L3 through L5 have not executed.</p><p>The executor returns the new action to schedule ReadSubtotal with ID 0. Once that action is accepted, scheduling history records the operation. The activity runs separately and reports its outcome.</p><p>Notice what is missing: no durable record of a local variable called subtotal with an instruction pointer beside it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2wGKbKMs4IVDaQQoxxK6gA.png" /><figcaption>Three snapshots: the newly delivered ExecutionStarted record is next to be read, L2 waits with candidate 0 and a pending result task, then the runner returns the new ReadSubtotal schedule.</figcaption></figure><h3>B: use the subtotal, then ask for the charge</h3><p>Now the available history includes the scheduled operation and a completion representing 100m.</p><p>The orchestration starts at L1 again. At L2, its code creates a candidate action <strong>0</strong> and another local task. Replay is not simply “look in a dictionary and instantly return a completed task.”</p><p>Core’s <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L327-L358">HandleTaskScheduledEvent</a> matches the recorded scheduling event against the reconstructed action using the sequence ID, action kind, and activity name. It then removes that candidate from the outbound action map.</p><p><strong>Matching the schedule removes the recreated action from the outgoing list; it does not, on its own, supply the result.</strong></p><p>The completion handler uses the recorded activity ID to find its open local task, supplies the serialized result, and then removes the entry. This excerpt from Durable Task Core’s <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L476-L484">TaskOrchestrationContext.HandleTaskCompletedEvent</a> keeps the existence check; the method wrapper and duplicate event branch are omitted:</p><pre>int taskId = completedEvent.TaskScheduledId;<br>if (this.openTasks.ContainsKey(taskId))<br>{<br>    OpenTaskInfo info = this.openTasks[taskId];<br>    info.Result.SetResult(completedEvent.Result);<br><br>    this.openTasks.Remove(taskId);<br>}</pre><p>The order matters: SetResult runs before removal, and the continuation can run inside that call. The snapshots show the settled state afterward.</p><p>When the executor processes the completion for ID 0, <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L476-L484">HandleTaskCompletedEvent sets the reconstructed completion source&#39;s result</a>. The async chain can then continue within this same activation, deserialize the value, and assign subtotal = 100m. Core&#39;s <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationExecutor.cs#L284-L305">orchestration synchronization context</a> and <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/SynchronousTaskScheduler.cs#L20-L33">synchronous task scheduler</a> keep those continuations inside this pass through history.</p><blockquote><strong><em>Why the context matters.</em></strong><em> When an ordinary </em><em>await needs to suspend, a captured </em><em>SynchronizationContext controls how its continuation, the code after that </em><em>await, is scheduled. The runtime </em><a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationExecutor.cs#L284-L305"><em>installs its own context</em></a><em> and uses </em><a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/SynchronousTaskScheduler.cs#L20-L33"><em>its scheduler</em></a><em> to keep that code in the runner&#39;s controlled history processing. </em><a href="https://learn.microsoft.com/en-us/azure/durable-task/common/durable-task-code-constraints"><em>ConfigureAwait(false) opts out of that capture</em></a><em> and can escape that path, so don&#39;t add it inside orchestrator code. It does not necessarily switch threads on every await.</em></blockquote><blockquote><em>Keeping this context does not promise the same OS thread or process across activations. Each reconstruction has its own objects and context. Neither normal capture nor </em><em>ConfigureAwait(true) makes </em><em>Task.Delay or HTTP I/O durable. Activities follow ordinary .NET async guidance separately.</em></blockquote><p>L3 calculates 100m + 10m, producing 110m. L4 calls ChargeOrder, creating the <strong>new</strong> action 1 with { orderId, total }. Its result is not available yet, so the method yields again.</p><p>The outstanding action is now <strong>Schedule 1: </strong><strong>ChargeOrder</strong>. There is no new Schedule 0 merely because L2 executed again.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*U526stum1Y0PwB2G4--kUA.png" /><figcaption>Four snapshots: replay recreates activity 0 and matches its earlier schedule. The result delivered for this step supplies 100, recomputes 110 and opens activity 1 at L4, then Schedule 1 is returned.</figcaption></figure><h3>C: use both recorded operations, then return</h3><p>With the second activity’s result available, the method again starts at L1.</p><p>L2 reconstructs action 0; history matches its schedule and supplies 100m. L3 calculates 110m again. That is a fresh calculation, not a saved local restored from a checkpoint.</p><p>L4 reconstructs action 1. Its recorded scheduling event removes the candidate from the outbound map, and its completion supplies &quot;receipt-7&quot;. L5 returns that receipt. The executor can now <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L714-L745">emit orchestration completion</a>, rather than a new activity schedule.</p><p>In this pass, result 0 comes from earlier history, while result 1 can be newly delivered for this step. Neither activity is newly scheduled just because both call sites ran again.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rWxz2FBKuanXQavQjHa4qw.png" /><figcaption>Six snapshots: replay rebuilds both awaits, matches their schedules, and recomputes 110. After all earlier history is read, IsReplaying becomes false while L4 still waits. The newly delivered completion 1 supplies receipt-7; L5 returns and the runner completes the orchestration.</figcaption></figure><p>Zoom in on C. Read the <strong>Earlier history</strong> cards from oldest to newest. <strong>Earlier events left</strong> counts the earlier records still to read, moving from 4 to 0 as the cursor advances. It is not a count of unfinished activities, and reading a card does not delete its stored record. <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L327-L358">Matching a schedule</a> removes a candidate action, while <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L476-L484">processing its completion</a> resolves the open task. One completion can advance several source lines, and an await can span several events.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XH-rhlm1w9cTqetX4RaLXA.png" /><figcaption>Activation C reads earlier history positions 1 through 4, keeping each card visible while the count of earlier events left to read falls from 4 to zero. After all four are read, IsReplaying becomes false while activity task 1 still waits. New result 5 then resolves L4 and lets L5 return receipt-7.</figcaption></figure><p>Only after the last record in the earlier history ( TaskScheduled for activity 1) has been processed does the <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationExecutor.cs#L138-L215">executor set </a><a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationExecutor.cs#L138-L215">IsReplaying to </a><a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationExecutor.cs#L138-L215">false, before processing new events</a>. L4 is still waiting on task 1. The newly delivered completion then resolves it, and L5 returns the receipt.</p><p>Reading all the earlier history does not mean every task has finished or every newly delivered event has been processed. It isn’t a general signal of workflow completion, and another activation can replay again.</p><p>Now suppose history contains TaskScheduled(0) but no completion or failure for it. Replay still matches and removes the candidate scheduling action, but the reconstructed task stays incomplete. <strong>Pending does not mean &quot;schedule it again because we replayed.&quot;</strong> Redelivery of an outstanding activity is a separate delivery concern.</p><p>No crash is needed for any of this. In the reconstruction path, normal activity results drive the same processing of history. Replay is part of normal progress, not just recovery.</p><h3>Can the runtime keep the local execution around?</h3><p>The available caching path depends on the host and its configuration.</p><p>The standalone gRPC path used above <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs#L840-L869">constructs a new </a><a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs#L840-L869">TaskOrchestrationExecutor</a>. A <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcOrchestrationRunner.cs#L14-L23">separate runner used primarily by Azure Functions isolated hosting</a> has an <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Grpc/GrpcOrchestrationRunner.cs#L145-L206">extended sessions path that can reuse an executor and process new events</a>. Functions also documents <a href="https://learn.microsoft.com/en-us/azure/durable-task/durable-functions/durable-functions-perf-and-scale#instance-caching">instance caching that varies by provider</a>.</p><p>That tells us what those paths can do, not which options a particular deployment enables. The diagrams show reconstruction, not a promise that every host unloads after every await or replays everything for each notification.</p><h3>The code restrictions stop looking arbitrary</h3><p>These mechanics explain why orchestrator code has to be deterministic.</p><p>For a given recorded history, the orchestration must reconstruct compatible durable operations in the required order. If a read of the wall clock, an unseeded random value, or an external response changes which operation comes next, the old history may no longer fit the new execution. Core performs useful matching checks, but that doesn’t guarantee it will detect every possible nondeterministic change for you.</p><p>For time, use <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/TaskOrchestrationContext.cs#L33-L60">context.CurrentUtcDateTime</a> rather than reading the wall clock directly. For a GUID that is safe to replay, use <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/TaskOrchestrationContext.cs#L444-L472">context.NewGuid()</a>. Replay safety is the point: NewGuid() is not a source of fresh, unpredictable randomness on every replay. If you need an external or independently random value, obtain it through an activity so its result can participate in the recorded history.</p><p>The same distinction applies to I/O. Awaiting HttpClient directly inside an orchestrator does not turn the HTTP request into a durable activity or record its response for replay. Put that I/O inside an activity. An HttpClient used <em>by the activity implementation</em> is a different case from an orchestrator awaiting it directly.</p><p>For a durable wait, the modern API here is <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/TaskOrchestrationContext.cs#L172-L207">context.CreateTimer(...)</a>, with TimeSpan or DateTime overloads and a cancellation token. Ordinary Task.Delay is normally nonblocking, but it is not a durable timer. The <a href="https://learn.microsoft.com/en-us/azure/durable-task/common/durable-task-code-constraints">orchestrator code constraints</a> follow from this boundary: ordinary async work does not become durable simply because it appears inside an orchestration method.</p><p>And then there are logs. L3 can execute more than once, so an ordinary log beside it can appear more than once. <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/TaskOrchestrationContext.cs#L444-L472">context.CreateReplaySafeLogger(&quot;OrderFlow&quot;)</a> returns a logger that <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/TaskOrchestrationContext.cs#L536-L546">suppresses writes while </a><a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/TaskOrchestrationContext.cs#L536-L546">IsReplaying is true</a>. That cuts replay noise. It doesn&#39;t guarantee that each audit event is recorded exactly once.</p><p>Do not “fix” repeated execution by placing durable actions inside if (!context.IsReplaying). Replay needs to reconstruct those actions to match the history. Suppressing the log is useful. Suppressing the operation changes the program.</p><h3>Replay is not retry</h3><p>There are three mechanisms worth keeping separate:</p><ol><li><strong>Replay of a recorded outcome:</strong> orchestration code executes again and history resolves its reconstructed task. That does not itself request another execution of a successfully recorded activity.</li><li><strong>A configured retry after failure:</strong> a recorded failure <a href="https://github.com/Azure/durabletask/blob/af8078ff7073facf5bb6ec8b7ba3beeb7efcf2d8/src/DurableTask.Core/TaskOrchestrationContext.cs#L492-L520">faults the reconstructed task</a>. An explicitly configured retry policy or handler can deliberately arrange another attempt; the SDK’s <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs#L127-L203">retry branches are explicit</a>.</li><li><strong>Redelivery of unacknowledged activity work:</strong> the activity execution contract is <a href="https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Abstractions/TaskActivity.cs#L41-L52">at least once</a>. An activity may execute again when its completion was not durably recorded, even if some external work already happened.</li></ol><p>The boundary to watch is inside ChargeOrder.</p><p>Suppose the payment system accepts the charge for 110m. Before the activity&#39;s completion is durably recorded, its worker fails. The payment system has done something real. The orchestration history does not yet contain a successful result it can replay.</p><p>When the activity runs again, it can reach that payment system again. History can recover orchestration progress; it cannot close the gap between <strong>the external effect succeeding</strong> and <strong>the activity outcome being durably recorded</strong>. Using a durable framework doesn’t put the payment system and the scheduler in one atomic transaction.</p><p>The charge operation still needs idempotency or deduplication at the business level. For example, use a stable key for this particular operation, honored by an integration that supports it. The key must distinguish a duplicate attempt from another legitimate charge.</p><p>Now move the failure to after the successful completion has been durably recorded. If worker memory is lost then, replay can supply &quot;receipt-7&quot; and advance to L5 without scheduling that completed logical activity again. That recovers a recorded outcome. It doesn&#39;t promise every external effect happened exactly once.</p><h3>Back to the debugger</h3><p>The await is still ordinary C#. The framework call creates an action to perform a durable operation and a local task. History can later match that operation and complete a reconstructed task, letting compatible code calculate its locals and continue.</p><p>So when you see L2 or L3 execute again, ask two separate questions: <strong>which source lines are being replayed, and which actions are actually being emitted?</strong></p><p>That is the secret sauce: the method can keep making progress without keeping its original stack alive. Its activities still have to handle external effects correctly.</p><p>Once you can follow the history and the actions, the apparent magic becomes something you can reason about in your own code. In upcoming posts, I’ll dig into more of the overlooked capabilities and hidden gems in the Durable Task ecosystem.</p><p><em>Originally published at </em><a href="https://www.tamirdresher.com/blog/2026/09/21/durable-await-replay"><em>https://www.tamirdresher.com</em></a><em> on September 20, 2026.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=111982be84b0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A payment journey in a FinTech company — Reversim podcast (Hebrew)]]></title>
            <link>https://engineering.payoneer.com/a-payment-journey-in-a-fintech-company-reversim-podcast-hebrew-c3b0c78a453f?source=rss-21fc7aa0950d------2</link>
            <guid isPermaLink="false">https://medium.com/p/c3b0c78a453f</guid>
            <category><![CDATA[fintech]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[microservices]]></category>
            <category><![CDATA[cross-border-payments]]></category>
            <dc:creator><![CDATA[Tamir Dresher]]></dc:creator>
            <pubDate>Mon, 16 Jan 2023 07:04:59 GMT</pubDate>
            <atom:updated>2023-05-14T06:34:25.345Z</atom:updated>
            <content:encoded><![CDATA[<h3>A payment journey in a FinTech company — Reversim podcast (Hebrew)</h3><figure><a href="https://www.reversim.com/2023/01/453-payment-journey-in-fintech-company.html"><img alt="" src="https://cdn-images-1.medium.com/max/765/1*0PLrVLuEHwSURX47MGmqKQ.png" /></a></figure><p>What happens from the moment a payment is initiated until it reaches its destination? Why is the traditional way to make a cross-border payment so expensive and what is the solution? Payoneer architects Yonatan and Tamir were hosted in the Israeli podcast “reversim” and talked about the journey a payment goes through under-the-hood of the Payoneer system.</p><p>Spotify: <a href="https://open.spotify.com/episode/2UAZvxBuI37TzCASPhfVvB">https://open.spotify.com/episode/2UAZvxBuI37TzCASPhfVvB</a></p><p>Reversim: <a href="https://www.reversim.com/2023/01/453-payment-journey-in-fintech-company.html">https://www.reversim.com/2023/01/453-payment-journey-in-fintech-company.html</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c3b0c78a453f" width="1" height="1" alt=""><hr><p><a href="https://engineering.payoneer.com/a-payment-journey-in-a-fintech-company-reversim-podcast-hebrew-c3b0c78a453f">A payment journey in a FinTech company — Reversim podcast (Hebrew)</a> was originally published in <a href="https://engineering.payoneer.com/">Payoneer Engineering, Product &amp; AI Transformation</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Story 1]]></title>
            <link>https://medium.com/tamirtest/story-1-44227406dbf3?source=rss-21fc7aa0950d------2</link>
            <guid isPermaLink="false">https://medium.com/p/44227406dbf3</guid>
            <dc:creator><![CDATA[Tamir Dresher]]></dc:creator>
            <pubDate>Wed, 16 Nov 2022 11:59:42 GMT</pubDate>
            <atom:updated>2022-11-16T11:59:42.573Z</atom:updated>
            <content:encoded><![CDATA[<p>A short story</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=44227406dbf3" width="1" height="1" alt=""><hr><p><a href="https://medium.com/tamirtest/story-1-44227406dbf3">Story 1</a> was originally published in <a href="https://medium.com/tamirtest">tamirtest</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>