<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Diagrid</title>
    <description>The latest articles on DEV Community by Diagrid (diagrid).</description>
    <link>https://dev.to/diagrid</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F6621%2Fca07de48-8345-4222-95f4-7c01627ee72e.png</url>
      <title>DEV Community: Diagrid</title>
      <link>https://dev.to/diagrid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/diagrid"/>
    <language>en</language>
    <item>
      <title>How Do I Stop Prompt Injection From Reaching My Tools?</title>
      <dc:creator>Josh van Leeuwen</dc:creator>
      <pubDate>Thu, 22 Oct 2026 11:48:58 +0000</pubDate>
      <link>https://dev.to/diagrid/how-do-i-stop-prompt-injection-from-reaching-my-tools-2hik</link>
      <guid>https://dev.to/diagrid/how-do-i-stop-prompt-injection-from-reaching-my-tools-2hik</guid>
      <description>&lt;p&gt;You can't stop untrusted text from reaching a model. If your agent reads public GitHub issues, anyone can put instructions into the text it reads. The model might follow them, even when you've told it not to.&lt;/p&gt;

&lt;p&gt;What you can control is what happens afterwards. Which tools the agent can call, what arguments they accept, and whether the model gets to make the decision to execute them at all.&lt;/p&gt;

&lt;p&gt;Take an agent that reads GitHub issues and applies labels. It needs to understand the issue, decide on a label and update GitHub. It doesn't need permission to close issues, merge pull requests or read repository secrets.&lt;/p&gt;

&lt;p&gt;Here's how I'd build this with Dapr.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the agent inside a workflow
&lt;/h2&gt;

&lt;p&gt;I'd use a Dapr Workflow to manage the issue triage process, rather than letting the model decide which actions to take.&lt;/p&gt;

&lt;p&gt;The workflow receives a verified GitHub webhook, passes the issue text to an agent and gets back a suggested label. It then checks that label before calling GitHub.&lt;/p&gt;

&lt;p&gt;The important part is what the agent doesn't control. It doesn't choose the repository, issue number or which GitHub operation to run. Those come from the workflow and the original verified event.&lt;/p&gt;

&lt;p&gt;The model is deciding how to classify the issue, not what it's allowed to do with GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat MCP tools as workflows too
&lt;/h2&gt;

&lt;p&gt;Dapr has an &lt;code&gt;MCPServer&lt;/code&gt; resource which lets you declare a connection to an MCP server. When Dapr loads it, it discovers the available tools and registers a durable workflow for each one.&lt;/p&gt;

&lt;p&gt;So if our GitHub MCP server exposes &lt;code&gt;add_label&lt;/code&gt; and &lt;code&gt;close_issue&lt;/code&gt;, Dapr registers workflows named:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dapr.internal.mcp.github.CallTool.add_label
dapr.internal.mcp.github.CallTool.close_issue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent doesn't need to manage the MCP connection itself. The application can invoke those workflows, and Dapr handles the MCP call.&lt;/p&gt;

&lt;p&gt;For example, the resource on our &lt;code&gt;github-tools&lt;/code&gt; application could look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dapr.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MCPServer&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;streamableHTTP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github-mcp.internal/mcp&lt;/span&gt;
&lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;github-tools&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL and tool names here are examples; you'd use those exposed by your own MCP server.&lt;/p&gt;

&lt;p&gt;Now we need to decide which applications can invoke those tool workflows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enforce permissions outside the model
&lt;/h2&gt;

&lt;p&gt;A list of permitted tools in a system prompt isn't a security control. The model can be convinced to ignore it.&lt;/p&gt;

&lt;p&gt;With Dapr, you can use a &lt;code&gt;WorkflowAccessPolicy&lt;/code&gt; to control which App IDs are allowed to schedule particular workflows on another application.&lt;/p&gt;

&lt;p&gt;For our example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dapr.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WorkflowAccessPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-tools-policy&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;github-tools&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;callers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;appID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;triage-workflow&lt;/span&gt;
      &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dapr.internal.mcp.github.CallTool.add_label&lt;/span&gt;
          &lt;span class="na"&gt;operations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows &lt;code&gt;triage-workflow&lt;/code&gt; to invoke &lt;code&gt;add_label&lt;/code&gt; on &lt;code&gt;github-tools&lt;/code&gt;. It doesn't grant access to &lt;code&gt;close_issue&lt;/code&gt;, or any of the other tools the MCP server might expose.&lt;/p&gt;

&lt;p&gt;The workflow access policy is enforced by Dapr, using the calling application's verified identity. The model doesn't get to override it.&lt;/p&gt;

&lt;p&gt;There are two details worth knowing here. You need a policy loaded for deny-by-default behaviour, and the MCP server workflows need to run under a separate App ID. Dapr always allows an application to schedule its own workflows, so you shouldn't rely on this policy to isolate tools running under the same App ID as the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the arguments before calling GitHub
&lt;/h2&gt;

&lt;p&gt;Being allowed to call &lt;code&gt;add_label&lt;/code&gt; doesn't mean every argument should be accepted.&lt;/p&gt;

&lt;p&gt;A poisoned issue could convince the model to suggest a label like &lt;code&gt;security-approved&lt;/code&gt;, or try to make it update a different issue.&lt;/p&gt;

&lt;p&gt;I'd have the agent return a label, then check it in the workflow before calling GitHub. Something like this, using the Dapr Python Workflow SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dapr.ext.workflow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;

&lt;span class="n"&gt;wfr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkflowRuntime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;ALLOWED_LABELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;needs-triage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@wfr.workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;triage_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;triage_issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DaprWorkflowContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# classify_issue is a registered activity that calls the model
&lt;/span&gt;    &lt;span class="n"&gt;suggestion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classify_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;suggestion&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ALLOWED_LABELS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rejected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_child_workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dapr.internal.mcp.github.CallTool.add_label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;github-tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;owner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issue_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the core of the workflow, with the activity implementation and runtime startup left out. The tool arguments will depend on your MCP server.&lt;/p&gt;

&lt;p&gt;Notice that the model only supplies the label. The repository and issue number come from the verified webhook that started the workflow.&lt;/p&gt;

&lt;p&gt;I'd also validate arguments where the tool runs. Dapr's &lt;code&gt;MCPServer&lt;/code&gt; resource supports &lt;code&gt;beforeCallTool&lt;/code&gt; hooks, which are themselves workflows. You can use one to reject labels outside the allow-list before the call reaches the MCP server.&lt;/p&gt;

&lt;p&gt;That way the check isn't only happening in the application that called the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a separate workflow for anything destructive
&lt;/h2&gt;

&lt;p&gt;Adding a label is one thing. Closing an issue, deleting something or deploying a release is another.&lt;/p&gt;

&lt;p&gt;For those operations, I'd use a separate workflow that waits for human approval.&lt;/p&gt;

&lt;p&gt;Dapr Workflows can wait for an external event, so the workflow can request approval, stop executing and resume when an authorised user responds. There's no need to keep a process running while someone makes the decision.&lt;/p&gt;

&lt;p&gt;The approval needs to be verified by your application, not accepted as something the model says it received.&lt;/p&gt;

&lt;p&gt;You can then give the review workflow its own App ID and grant it access to &lt;code&gt;close_issue&lt;/code&gt;, without giving that permission to the triage workflow.&lt;/p&gt;

&lt;p&gt;The same applies to any other tool where a bad decision is difficult to undo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't fix
&lt;/h2&gt;

&lt;p&gt;None of this stops prompt injection from influencing the model. A malicious GitHub issue might still persuade it to suggest the wrong label.&lt;/p&gt;

&lt;p&gt;But the model doesn't have permission to close the issue, the workflow won't accept a label outside the allowed set, and the repository and issue number aren't under the model's control.&lt;/p&gt;

&lt;p&gt;That's a much smaller problem than giving an agent access to every tool on the MCP server and relying on a system prompt to keep it in line.&lt;/p&gt;

&lt;p&gt;You still need to think about prompt injection. You just shouldn't be relying on the model to enforce the limits of what it's allowed to do.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Dapr documentation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.dapr.io/developing-ai/mcp/mcp-server-resource/" rel="noopener noreferrer"&gt;MCPServer resources&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.dapr.io/operations/security/workflow-access-policy/" rel="noopener noreferrer"&gt;Workflow access policies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-multi-app/" rel="noopener noreferrer"&gt;Multi-application workflows&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-patterns/" rel="noopener noreferrer"&gt;Workflow patterns and external events&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>dapr</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Test AI Agent Crash Recovery Locally with Dapr Workflow</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Tue, 20 Oct 2026 06:33:12 +0000</pubDate>
      <link>https://dev.to/diagrid/how-to-test-ai-agent-crash-recovery-locally-with-dapr-workflow-5he7</link>
      <guid>https://dev.to/diagrid/how-to-test-ai-agent-crash-recovery-locally-with-dapr-workflow-5he7</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Most advice on testing AI agents is about the answers, such as evals and golden datasets. Crash recovery needs its own test. If your agent runs as a durable workflow, a restart should resume the same run without repeating finished steps or side effects. You can prove that on your laptop, but only if the crash happens at exactly the same place every time, and pressing Ctrl+C half a second late does not give you that. In this post you put the crash in the code, run a pytest integration test against a Dapr Workflow agent, and check the outcome by counting lines in files. For background on how replay skips completed steps, read the &lt;a href="https://www.diagrid.io/learn/durable-agents" rel="noopener noreferrer"&gt;durable agents guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Prerequisites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python 3.10 or later&lt;/li&gt;
&lt;li&gt;Docker and the Dapr CLI, initialized with &lt;code&gt;dapr init&lt;/code&gt; (tested with Dapr 1.18)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pip install dapr-ext-workflow pytest&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The commands in this post run unchanged in bash and in Windows PowerShell, except the manual run in the verification section, which shows both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the side effect countable
&lt;/h2&gt;

&lt;p&gt;The agent triages support tickets in two steps. The &lt;code&gt;summarize&lt;/code&gt; activity stands in for your LLM call, and &lt;code&gt;post_comment&lt;/code&gt; stands in for the tool that writes to the ticket. Replace both with something you can count: &lt;code&gt;summarize&lt;/code&gt; appends the workflow ID to &lt;code&gt;llm_calls.txt&lt;/code&gt;, and &lt;code&gt;post_comment&lt;/code&gt; appends one line to &lt;code&gt;ledger.txt&lt;/code&gt;. One comment is one line, so correct means one line. &lt;code&gt;DATA_DIR&lt;/code&gt; lets every test run write to its own folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dapr.ext.workflow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;

&lt;span class="n"&gt;wfr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkflowRuntime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;DATA_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATA_DIR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="nd"&gt;@wfr.workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;triage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DaprWorkflowContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post_comment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@wfr.activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATA_DIR&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm_calls.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# your LLM call goes here
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summary: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@wfr.activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post_comment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_comment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATA_DIR&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ledger.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;wfr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DaprWorkflowClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule_new_workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Login page 500s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_workflow_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout_in_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STATUS=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runtime_status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;wfr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save this as &lt;code&gt;agent.py&lt;/code&gt;. The &lt;code&gt;start&lt;/code&gt; mode schedules a new workflow instance and the &lt;code&gt;resume&lt;/code&gt; mode only waits for an existing one, which is what the restart needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add named crash points
&lt;/h2&gt;

&lt;p&gt;You can stop the process with Ctrl+C or &lt;code&gt;kill&lt;/code&gt;, but you will press it half a second late, after the step has finished, and the run proves nothing. A crash you cannot place exactly is a crash you cannot repeat, and a test you cannot repeat never makes it into CI.&lt;/p&gt;

&lt;p&gt;Instead, add a function that exits the process when the &lt;code&gt;CRASH_AT&lt;/code&gt; environment variable names the current step. &lt;code&gt;os._exit(1)&lt;/code&gt; skips cleanup and &lt;code&gt;finally&lt;/code&gt; blocks, so it behaves like a power cut. Add this below the &lt;code&gt;DATA_DIR&lt;/code&gt; line in &lt;code&gt;agent.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CRASH_AT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRASH_AT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;crash_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CRASH_AT&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crashing at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# no cleanup, no finally blocks
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then place two crash points in &lt;code&gt;post_comment&lt;/code&gt;. The first fires before the write, and the second fires after the write but before the activity returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@wfr.activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post_comment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_comment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;crash_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before_post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATA_DIR&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ledger.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;crash_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;after_post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Write the crash recovery test
&lt;/h2&gt;

&lt;p&gt;The test starts the agent under &lt;code&gt;dapr run&lt;/code&gt; as a subprocess, once with the crash switch on and once with it off. The first run must print the crash message and must not complete. The second run must report &lt;code&gt;COMPLETED&lt;/code&gt;, and the files must hold the expected counts. Save this as &lt;code&gt;test_crash_recovery.py&lt;/code&gt; next to &lt;code&gt;agent.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;

&lt;span class="n"&gt;pytestmark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;integration&lt;/span&gt;

&lt;span class="n"&gt;AGENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crash_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATA_DIR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_dir&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;crash_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRASH_AT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crash_at&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dapr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--app-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AGENT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;count_lines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_crash_before_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;triage-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;crashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crash_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before_post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crashing at before_post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;crashed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STATUS=COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;crashed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;

    &lt;span class="n"&gt;resumed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resume&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STATUS=COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resumed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resumed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;count_lines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ledger.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;count_lines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm_calls.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every test generates a new instance ID and app ID, so state left behind by earlier runs cannot make a test pass for the wrong reason. Both runs share the same app ID because the workflow state belongs to that app. Each test also writes to its own &lt;code&gt;tmp_path&lt;/code&gt;, so the line counts start at zero.&lt;/p&gt;

&lt;p&gt;Register the &lt;code&gt;integration&lt;/code&gt; marker in &lt;code&gt;pytest.ini&lt;/code&gt;, so these slower tests stay out of your fast unit run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[pytest]&lt;/span&gt;
&lt;span class="py"&gt;markers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="err"&gt;integration:&lt;/span&gt; &lt;span class="err"&gt;runs&lt;/span&gt; &lt;span class="err"&gt;the&lt;/span&gt; &lt;span class="err"&gt;agent&lt;/span&gt; &lt;span class="err"&gt;under&lt;/span&gt; &lt;span class="err"&gt;dapr&lt;/span&gt; &lt;span class="err"&gt;run&lt;/span&gt; &lt;span class="err"&gt;and&lt;/span&gt; &lt;span class="err"&gt;needs&lt;/span&gt; &lt;span class="err"&gt;a&lt;/span&gt; &lt;span class="err"&gt;local&lt;/span&gt; &lt;span class="err"&gt;Dapr&lt;/span&gt; &lt;span class="err"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Handle a crash after the side effect
&lt;/h2&gt;

&lt;p&gt;The two crash points show different things.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;before_post&lt;/code&gt;, &lt;code&gt;summarize&lt;/code&gt; has already completed and been recorded. On restart it is not run again, so &lt;code&gt;llm_calls.txt&lt;/code&gt; keeps one line (no second LLM call and no second token bill), and the ledger ends with one line.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;after_post&lt;/code&gt;, the comment was written but the activity never returned, so the runtime has no record that it finished. It runs &lt;code&gt;post_comment&lt;/code&gt; again and the ledger gets two lines. That is correct behavior for an activity that crashed mid-flight, which is why a side-effecting activity needs a "did I already do this?" check keyed on &lt;code&gt;ctx.workflow_id&lt;/code&gt;. Add one to &lt;code&gt;agent.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;IDEMPOTENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IDEMPOTENT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;already_posted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DATA_DIR&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ledger.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@wfr.activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post_comment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_comment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;IDEMPOTENT&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;already_posted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;already posted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;crash_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before_post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATA_DIR&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ledger.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;crash_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;after_post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;posted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;IDEMPOTENT&lt;/code&gt; switch only exists so the test can show both outcomes. In your own agent the check is always on, and the key is usually the idempotency key of the system you write to, such as your ticketing tool.&lt;/p&gt;

&lt;p&gt;Now parametrize the test over the three cases. Pass the new &lt;code&gt;idempotent&lt;/code&gt; option through &lt;code&gt;run_agent&lt;/code&gt;, and replace &lt;code&gt;test_crash_before_post&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crash_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATA_DIR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_dir&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IDEMPOTENT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;idempotent&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;crash_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRASH_AT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crash_at&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dapr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--app-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AGENT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crash_at, idempotent, expected_comments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before_post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;after_post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# at-least-once: the comment is written twice
&lt;/span&gt;        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;after_post&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_crash_recovery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crash_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_comments&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;triage-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;crashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crash_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crashing at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;crash_at&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;crashed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STATUS=COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;crashed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;

    &lt;span class="n"&gt;resumed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resume&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;idempotent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STATUS=COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resumed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resumed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;count_lines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ledger.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected_comments&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;count_lines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm_calls.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;# summarize is never replayed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second case asserts two lines on purpose. It documents the at-least-once behavior, so a future change to the runtime or to your activity cannot alter it without a failing test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the test and watch the run
&lt;/h2&gt;

&lt;p&gt;Run the integration tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest &lt;span class="nt"&gt;-m&lt;/span&gt; integration &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three cases should pass in about 20 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test_crash_recovery.py::test_crash_recovery[before_post-False-1] PASSED
test_crash_recovery.py::test_crash_recovery[after_post-False-2] PASSED
test_crash_recovery.py::test_crash_recovery[after_post-True-1] PASSED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To watch a single run in detail, start the &lt;a href="https://docs.diagrid.io/develop/local-development/dev-dashboard/" rel="noopener noreferrer"&gt;Dapr Dev Dashboard&lt;/a&gt; in a second terminal with &lt;code&gt;diagrid-dev-dashboard&lt;/code&gt; and open &lt;a href="http://localhost:9090" rel="noopener noreferrer"&gt;http://localhost:9090&lt;/a&gt;. Then crash and restart the agent by hand, using a fresh instance ID. In bash on macOS or Linux:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ticket-&lt;span class="nv"&gt;$RANDOM&lt;/span&gt;
&lt;span class="nv"&gt;CRASH_AT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;before_post dapr run &lt;span class="nt"&gt;--app-id&lt;/span&gt; triage &lt;span class="nt"&gt;--&lt;/span&gt; python agent.py start &lt;span class="nv"&gt;$ID&lt;/span&gt;
dapr run &lt;span class="nt"&gt;--app-id&lt;/span&gt; triage &lt;span class="nt"&gt;--&lt;/span&gt; python agent.py resume &lt;span class="nv"&gt;$ID&lt;/span&gt;
&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; ledger.txt llm_calls.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Windows PowerShell, set the environment variable for the first run and remove it before the restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ticket-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="n"&gt;Get-Random&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;CRASH_AT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"before_post"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--app-id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;triage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;python&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;agent.py&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Remove-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Env:CRASH_AT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;dapr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--app-id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;triage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;python&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;agent.py&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;resume&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ID&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-Content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ledger.txt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Measure-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lines&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-Content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;llm_calls.txt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Measure-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lines&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Select the instance in the dashboard to see its event history: which activity completed before the crash, and which one was scheduled again after the restart. To run the same check in CI, give the runner Docker, run &lt;code&gt;dapr init&lt;/code&gt;, and add &lt;code&gt;pytest -m integration&lt;/code&gt; next to your evals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;If a test passes suspiciously fast, check the instance ID and app ID first. Workflow state persists in your local state store, so resuming an instance that finished in an earlier run reports &lt;code&gt;COMPLETED&lt;/code&gt; right away, even when nothing crashed and nothing was written. The unique IDs in the test prevent this. When you run the commands by hand, use a fresh &lt;code&gt;$ID&lt;/code&gt; every time.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;dapr run&lt;/code&gt; output can show actor retry warnings while the restarted app registers with the placement service. In these runs the workflow still completed, so keep the 60 second wait timeout generous instead of treating those warnings as a failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Crash recovery is now a test that fails when recovery breaks. A named crash point, a restart with the switch off, and a ledger you can count tell you what your agent did after it died. Both crash points should end at one line: &lt;code&gt;before_post&lt;/code&gt; because completed steps are never replayed, and &lt;code&gt;after_post&lt;/code&gt; because your activity checks whether it already did the work. Add the pytest run to CI next to your evals, and open the &lt;a href="https://docs.diagrid.io/develop/local-development/dev-dashboard/" rel="noopener noreferrer"&gt;Dev Dashboard&lt;/a&gt; whenever you want to see the event history behind a result. The &lt;a href="https://www.diagrid.io/learn/durable-agents" rel="noopener noreferrer"&gt;durable agents guide&lt;/a&gt; explains checkpointing and replay in more detail, and &lt;a href="https://www.diagrid.io/university" rel="noopener noreferrer"&gt;Dapr University&lt;/a&gt; has courses on durable execution and Dapr.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>programming</category>
      <category>testing</category>
    </item>
    <item>
      <title>The Three Failures Every Fault Tolerant AI Agent Must Handle</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:32:46 +0000</pubDate>
      <link>https://dev.to/diagrid/the-three-failures-every-fault-tolerant-ai-agent-must-handle-1e1g</link>
      <guid>https://dev.to/diagrid/the-three-failures-every-fault-tolerant-ai-agent-must-handle-1e1g</guid>
      <description>&lt;p&gt;"Make the agent fault tolerant" sounds like one task. It is three. Your agent can fail loudly, with an error you can see and retry. It can fail silently, when a side effect fires and the process dies before anything records it. And it can fail by waiting, when an approval takes two days and the process holding that wait gets restarted on day one. Each class needs a different mechanism, and most agent frameworks only ship the first. That is why demos look fault tolerant and production agents send the same email twice or lose an approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fault tolerance is three assignments
&lt;/h2&gt;

&lt;p&gt;When you move an agent from prototype to production, "make it fault tolerant" lands in the backlog as one ticket. You handle the failures you've seen in testing, and those are almost always the loud ones: a tool returns a 500, the model API times out, the LLM sends back malformed JSON.&lt;/p&gt;

&lt;p&gt;The other failures rarely show up on your laptop. They show up during a deploy, when the orchestrator kills a pod for using too much memory, when the cloud provider reclaims a spot instance, or when a run sits waiting for a human for three days. Fault tolerance for an agent covers three distinct classes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Visible failures, where something errors and you know about it.&lt;/li&gt;
&lt;li&gt;Invisible failures, where work happens but the record of it is lost.&lt;/li&gt;
&lt;li&gt;Long waits, where the agent has to outlive the process running it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Visible failures and the retry toolbox
&lt;/h2&gt;

&lt;p&gt;A visible failure announces itself. The tool call returns an error, the API times out with a clear exception, the model refuses the request or returns output your parser rejects.&lt;/p&gt;

&lt;p&gt;The toolbox for this class is well known:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retry with exponential backoff, so a rate-limited or briefly unavailable service gets time to recover.&lt;/li&gt;
&lt;li&gt;Cap the number of attempts, so a permanent error doesn't turn into an infinite loop that burns tokens.&lt;/li&gt;
&lt;li&gt;Dead-letter the requests that keep failing, so a person or a separate process can look at them later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Almost every agent framework and HTTP client ships some version of this, so it's the part of fault tolerance most teams already have.&lt;/p&gt;

&lt;p&gt;Retries have one built-in assumption, though: the failed attempt had no effect. A retry repeats a step; it doesn't know whether the previous attempt already sent the email or charged the card before the error came back. The next class is where it breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Invisible failures and why checkpointing falls short
&lt;/h2&gt;

&lt;p&gt;An invisible failure happens in the gap between doing something and recording that you did it. Your agent calls the payment API and the charge goes through. Before the agent writes "payment done" anywhere, the process disappears. A rolling deploy replaced the pod, the kernel's out-of-memory killer picked your container, or the spot instance was reclaimed. No exception is raised and no retry policy fires, because the code that would catch the error is gone too.&lt;/p&gt;

&lt;p&gt;Many agent frameworks answer this with checkpointing. It saves a snapshot of the agent's state after each step, and a restarted agent loads the latest one. If the process dies during a step or right after it, that snapshot is from before the side effect. The restored agent has no idea the charge happened, and it charges again.&lt;/p&gt;

&lt;p&gt;Durable execution closes most of that gap by making the record part of execution itself. The engine writes "scheduled" to a journal before a step runs, and "completed" with the result once it returns. On restart it replays the journal, skips every step marked completed, uses the recorded results instead of calling the tools again, and resumes at the first unfinished step. This is the class of failure that separates a demo agent from a production one.&lt;/p&gt;

&lt;p&gt;There's still a small window left: the side effect can succeed just before the process dies and before "completed" is written. In that case the engine reruns the step, which is why durable execution engines guarantee that a step runs at least once rather than exactly once. The journal gets you back to the right step. Making the side effect happen once is the job of the step itself: pass an idempotency key to the downstream service so it recognizes the repeat and returns the original result. Dapr Workflow gives every scheduled task a task execution ID that stays the same across retries, so you can use it as that key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Long waits and durable timers
&lt;/h2&gt;

&lt;p&gt;Agents spend a lot of their lives waiting. A refund over a certain amount needs a manager's approval, which might come in two hours or two days. A procurement agent can't confirm delivery until the container ships on Tuesday.&lt;/p&gt;

&lt;p&gt;Processes don't live that long. Over a two-day approval window your agent will probably go through several deploys, a node upgrade or two, and maybe an autoscaler scaling it down overnight. A wait held in process memory, like a sleep call, an in-memory promise or a loop polling an inbox, disappears with the process. Any wait held in memory is a failure scheduled in advance; you just don't know the exact time yet.&lt;/p&gt;

&lt;p&gt;Durable execution handles this by moving the wait into the journal as well. A durable timer records "wake me at this time," and an external event records "resume when this approval arrives." While the agent waits, nothing needs to run: the process can stop, restart or move to another node. When the timer fires or the event comes in, the engine loads the journal and continues from the waiting step. Pair the approval event with a timer so that if nobody responds within three days the agent escalates or cancels instead of waiting forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crash test that checks all three
&lt;/h2&gt;

&lt;p&gt;You can check your agent against both of the harder classes with two tests. Both use a force-kill, meaning the process ends right away with no graceful shutdown and no cleanup handlers. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In a container, use &lt;code&gt;docker kill&lt;/code&gt;, or delete the pod with a zero grace period in Kubernetes.&lt;/li&gt;
&lt;li&gt;On Windows, end the process from Task Manager or with &lt;code&gt;taskkill /F&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On macOS, use Force Quit in Activity Monitor or &lt;code&gt;kill -9&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On Linux, use &lt;code&gt;kill -9&lt;/code&gt; with the process ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the first test, start a run that makes a side-effecting call, such as sending an email or creating a record in another system. Force-kill the process right after that call succeeds, restart it, and let the run finish. Then count the effects in the downstream system. One is the only right answer. If you see two, the step needs an idempotency key.&lt;/p&gt;

&lt;p&gt;For the second test, start a run that waits for an approval or a timer. Force-kill the process during the wait, restart it, then send the approval. The agent should pick up the event and continue.&lt;/p&gt;

&lt;p&gt;Most teams skip these tests because they tend to fail the first time. That is exactly why you should run them before production does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs and considerations
&lt;/h2&gt;

&lt;p&gt;Durable execution isn't free. Every step writes to the journal before and after it runs, which adds a little latency and storage per step. For steps with side effects or long waits, that cost is almost always worth paying; for a quick in-memory computation it usually isn't.&lt;/p&gt;

&lt;p&gt;The orchestration logic also has to be deterministic, because the engine replays it to rebuild state. Keep LLM calls, tool calls and anything else non-deterministic inside activities, and keep the orchestration code limited to deciding what runs next.&lt;/p&gt;

&lt;p&gt;Not every agent needs all three layers either. A stateless chat helper that only reads data is fine with the retry toolbox alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Fault tolerance for AI agents is three separate problems. Retries with backoff and dead-lettering handle visible failures. Invisible failures need more than checkpointing: durable execution writes each step to a journal before and after it runs, so recovery resumes at the right step, and an idempotency key makes sure the side effect lands once. Long waits belong in the journal too, as durable timers and external events, so a restart can't erase them. The two force-kill tests tell you which of these your agent already handles. To see these ideas in a small working example, read &lt;a href="https://www.diagrid.io/blog/the-tiniest-durable-agent" rel="noopener noreferrer"&gt;The Tiniest Durable Agent&lt;/a&gt;. To learn durable timers, external events, and recovery hands-on, take the free &lt;a href="https://www.diagrid.io/university/workflow" rel="noopener noreferrer"&gt;Dapr Workflow course on Dapr University&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Agents Are For-Loops. That Is Good News for Security.</title>
      <dc:creator>Josh van Leeuwen</dc:creator>
      <pubDate>Tue, 13 Oct 2026 09:42:17 +0000</pubDate>
      <link>https://dev.to/diagrid/agents-are-for-loops-that-is-good-news-for-security-598</link>
      <guid>https://dev.to/diagrid/agents-are-for-loops-that-is-good-news-for-security-598</guid>
      <description>&lt;p&gt;&lt;em&gt;If agents were magic, we couldn’t secure them. They aren’t, so we can.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgz928gfegxbfpt5dtbeh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgz928gfegxbfpt5dtbeh.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AI agents are not magic. They are workflows with probabilistic decision-making and real-world privileges. Strip away the framing and you find the classical operator feedback loop you already know from Kubernetes controllers and embedded thermostats: observe, decide, act, repeat. The only new element is that the deciding step is a language model.&lt;/p&gt;

&lt;p&gt;This is good news, because it means twenty years of distributed-systems security applies almost unchanged. The model is the part you cannot trust; everything around it is the part you already know how to lock down.&lt;/p&gt;

&lt;h2&gt;
  
  
  LLMs don’t act. Tools do.
&lt;/h2&gt;

&lt;p&gt;A model emits instructions. It’s the tools in the acting step that translate the model’s intent into real-world behaviour- the database write, the refund, the deployment. Which means the security boundary is not the prompt and never was. It’s the tool layer, and tool access is an authorisation problem we have mature answers for: each agent gets its own verifiable identity, each identity gets explicitly scoped grants, and everything ungrated is denied by default.&lt;/p&gt;

&lt;p&gt;Note what this replaces. The common pattern today is one shared API key in an environment variable, used by every agent, with the real policy written in the system prompt as a polite request. A permission that exists only as an instruction to a model is not a permission. And a gateway in front of the tools helps less than it appears: a gateway secures a path, it cannot secure a workload it does not run. If the agent can reach the backing service directly, the gateway is decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a secured for-loop looks like
&lt;/h2&gt;

&lt;p&gt;Identity issued per agent, short-lived and cryptographically verifiable (SPIFFE gives you the standard). Authorisation scoped per tool, per agent, enforced in the runtime. Deny-all as the starting posture, grants as deliberate exceptions. And a signed execution history, so “what did the agent do” has an answer that survives an audit rather than a log file that anyone could have edited.&lt;/p&gt;

&lt;p&gt;There is still a weakness in this system: prompt injection against a tool the agent is legitimately granted. If the agent is allowed to send email and a poisoned document convinces the model to send the wrong one, identity and authorisation did their job and the damage still happened. No infrastructure makes a model trustworthy. What infrastructure can do is keep the set of things a misbehaving model can reach small- which turns a catastrophe into an incident.&lt;/p&gt;

&lt;p&gt;The deeper treatments: &lt;a href="https://www.diagrid.io/blog/agent-identity-the-foundational-layer-that-ai-is-still-missing" rel="noopener noreferrer"&gt;Agent Identity: The Foundational Layer AI Is Still Missing&lt;/a&gt; and &lt;a href="https://www.diagrid.io/blog/why-mcp-gateways-are-not-enough" rel="noopener noreferrer"&gt;Why MCP Gateways Are Not Enough&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>identity</category>
      <category>zerotrust</category>
    </item>
    <item>
      <title>Understanding At-Least-Once Execution and Why It Matters for Workflow &amp; Agent Developers</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Thu, 08 Oct 2026 12:51:24 +0000</pubDate>
      <link>https://dev.to/diagrid/understanding-at-least-once-execution-and-why-it-matters-for-workflow-developers-3nen</link>
      <guid>https://dev.to/diagrid/understanding-at-least-once-execution-and-why-it-matters-for-workflow-developers-3nen</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Durable execution guarantees that your workflow finishes. It does not guarantee that each step runs only once. Picture a workflow that charges a credit card for 49 EUR. The payment provider accepts the charge, and then the pod is evicted before the workflow records the result. On restart, the workflow sees an unfinished step and charges the card again. Your customer now pays 98 EUR.&lt;/p&gt;

&lt;p&gt;If you are new to durable execution, this can feel like a bug in the engine. It is not. Durable execution engines, including Dapr Workflow, run activities at least once, and making a side effect happen exactly once is the job of your activity code. In this post, you'll see why the engine behaves this way, how to spot the steps that need protection, and how to use the task execution ID in a C# activity as an idempotency key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Durable Execution Guarantees Completion, Not Single Execution
&lt;/h2&gt;

&lt;p&gt;A Dapr Workflow app has two kinds of code. The workflow code decides what happens next: call this activity, wait for that event, branch on a result. The activity code does the actual work that touches the outside world, such as calling a payment provider, writing to a database, or sending an email. The workflow engine persists every step, so when your app crashes it can pick up where it left off instead of starting over. If you want a primer on this model, watch &lt;a href="https://www.diagrid.io/videos/failure-is-not-an-option-durable-execution-dapr-2" rel="noopener noreferrer"&gt;this session on durable execution with Dapr&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There is a catch in "pick up where it left off". The engine can only store that a step happened after the step has happened. Between the moment your activity charges the card and the moment the engine writes down the result, there is always a window. If the process dies inside that window, the engine has no record of the charge, so the only safe choice is to run the activity again.&lt;/p&gt;

&lt;p&gt;That is why durable execution gives you completion, not exactly-once execution. Exactly-once side effects come from making each activity idempotent: running it twice with the same input has the same effect as running it once. This is not specific to Dapr. Temporal and Azure Durable Functions make the same trade-off and give the same advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Dapr Workflow Records Progress
&lt;/h2&gt;

&lt;p&gt;Here is the order workflow from the intro, written with the Dapr .NET SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderWorkflow&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WorkflowContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChargeCardActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChargeRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CallActivityAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ShipOrderActivity&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow code does not call &lt;code&gt;ChargeCardActivity&lt;/code&gt; directly. The &lt;a href="https://docs.dapr.io/contributing/protocol-reference/workflow-protocol/workflow-protocol-orchestration-lifecycle/" rel="noopener noreferrer"&gt;orchestration lifecycle&lt;/a&gt; describes what happens instead. The Dapr sidecar sends your app an &lt;code&gt;OrchestratorWorkItem&lt;/code&gt; over a long-lived &lt;code&gt;GetWorkItems&lt;/code&gt; gRPC stream. That work item contains the full history of the workflow instance. The SDK replays your &lt;code&gt;RunAsync&lt;/code&gt; method from the top against that history. When it reaches &lt;code&gt;CallActivityAsync&lt;/code&gt;, it looks for a recorded result. If there is one, it returns the recorded result without running the activity. If there isn't, it emits a &lt;code&gt;ScheduleTask&lt;/code&gt; action and suspends the workflow.&lt;/p&gt;

&lt;p&gt;The engine then appends a &lt;code&gt;TaskScheduled&lt;/code&gt; event to the history and dispatches the activity. When the activity reports back, the engine records a &lt;code&gt;TaskCompleted&lt;/code&gt; (or &lt;code&gt;TaskFailed&lt;/code&gt;) event, and the next replay picks up the result.&lt;/p&gt;

&lt;p&gt;According to the &lt;a href="https://docs.dapr.io/contributing/protocol-reference/workflow-protocol/workflow-protocol-state-and-history/" rel="noopener noreferrer"&gt;state and history&lt;/a&gt; docs, this history is the source of truth. It is stored in your configured state store as a list of serialized events, with keys like &lt;code&gt;wf-history-&amp;lt;instance_id&amp;gt;-&amp;lt;index&amp;gt;&lt;/code&gt;. For the order workflow, the history at the moment of the pod eviction looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ExecutionStarted   OrderWorkflow
TaskScheduled      ChargeCardActivity
                   &amp;lt;- pod evicted here, card already charged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;TaskCompleted&lt;/code&gt; for &lt;code&gt;ChargeCardActivity&lt;/code&gt;. As far as the engine knows, the charge never finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Activities Run at Least Once
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.dapr.io/contributing/protocol-reference/workflow-protocol/workflow-protocol-activity-lifecycle/" rel="noopener noreferrer"&gt;activity lifecycle&lt;/a&gt; shows where the window sits. When the engine dispatches an activity, the sidecar sends an &lt;code&gt;ActivityWorkItem&lt;/code&gt; over the same &lt;code&gt;GetWorkItems&lt;/code&gt; stream. The work item carries the activity &lt;code&gt;name&lt;/code&gt;, its &lt;code&gt;input&lt;/code&gt;, the workflow &lt;code&gt;instance_id&lt;/code&gt;, a &lt;code&gt;task_id&lt;/code&gt;, a &lt;code&gt;task_execution_id&lt;/code&gt;, and a &lt;code&gt;completion_token&lt;/code&gt;. The SDK runs your activity code and then calls &lt;code&gt;CompleteActivityTask&lt;/code&gt; with the &lt;code&gt;result&lt;/code&gt; (or &lt;code&gt;failure_details&lt;/code&gt;) and the &lt;code&gt;completion_token&lt;/code&gt;, as defined in the &lt;a href="https://docs.dapr.io/contributing/protocol-reference/workflow-protocol/workflow-protocol-execution-api/" rel="noopener noreferrer"&gt;execution API&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Your side effect happens while the activity runs. The engine learns about it only when &lt;code&gt;CompleteActivityTask&lt;/code&gt; arrives. Anything that breaks the chain between those two points, such as a pod eviction, an out-of-memory kill, a node failure, or a dropped network connection, leaves a &lt;code&gt;TaskScheduled&lt;/code&gt; event without a matching &lt;code&gt;TaskCompleted&lt;/code&gt;. The activity lifecycle doc names this case directly: activities "might be executed more than once (e.g., if the worker crashes after execution but before reporting completion)".&lt;/p&gt;

&lt;p&gt;Retry policies are a second source of repeats. If an activity fails and the workflow has a retry policy, the engine schedules the activity again with the same name and input. A failure from your activity's point of view is not always a failure on the other side. If the HTTP call to the payment provider times out, your activity throws, yet the provider may have processed the charge anyway.&lt;/p&gt;

&lt;p&gt;The third case is a slow worker. If a worker takes too long and the task is handed to another worker, the original one can still finish. The sidecar uses the &lt;code&gt;completion_token&lt;/code&gt; to ignore the late response, but by then the side effect has already happened.&lt;/p&gt;

&lt;p&gt;In all three cases, the engine protects its own bookkeeping, not your side effects. That is why at-least-once is the norm for durable execution engines.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Spot the Steps That Need Idempotency
&lt;/h2&gt;

&lt;p&gt;Not every activity needs extra work. For each activity in your workflow, ask one question: if this runs twice with the same input, is the outcome different from running it once?&lt;/p&gt;

&lt;p&gt;Activities that usually need protection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Charging, refunding, or transferring money&lt;/li&gt;
&lt;li&gt;Sending emails, text messages, or push notifications&lt;/li&gt;
&lt;li&gt;Inserting records with generated IDs, where a second insert creates a duplicate row&lt;/li&gt;
&lt;li&gt;Publishing messages to a broker or event stream&lt;/li&gt;
&lt;li&gt;Incrementing counters or adjusting stock levels&lt;/li&gt;
&lt;li&gt;Calling an LLM tool or external API that acts on another system, such as creating a ticket&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Activities that are usually safe already:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pure computation on the input&lt;/li&gt;
&lt;li&gt;Reads, such as GET requests and database queries&lt;/li&gt;
&lt;li&gt;Upserts keyed on a natural ID, such as the order ID&lt;/li&gt;
&lt;li&gt;Setting a value to a fixed state, such as "set order status to Shipped"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't forget compensation activities. If your workflow uses the saga pattern, the refund that undoes a charge can run twice as well, and refunding a customer twice is just as wrong as charging them twice.&lt;/p&gt;

&lt;p&gt;Keep this separate from workflow determinism. Determinism is about your workflow code producing the same decisions on every replay, so no &lt;code&gt;DateTime.UtcNow&lt;/code&gt;, random numbers, or direct I/O in &lt;code&gt;RunAsync&lt;/code&gt;. Idempotency is about your activity code having the same effect when it runs more than once. You need both, but they solve different problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using the Task Execution ID as an Idempotency Key
&lt;/h2&gt;

&lt;p&gt;The standard fix is an idempotency key. You send a key with the request, and the receiving service remembers which keys it has already processed. When the same key arrives a second time, the service returns the original result instead of doing the work again. Most payment providers support this through an &lt;code&gt;Idempotency-Key&lt;/code&gt; HTTP header.&lt;/p&gt;

&lt;p&gt;The key has to be the same every time the activity runs for the same step, and different for every other step. Since Dapr .NET SDK v1.17, &lt;code&gt;WorkflowActivityContext&lt;/code&gt; exposes a &lt;code&gt;TaskExecutionId&lt;/code&gt; property that fits this. The ID is created when the workflow schedules the activity and is stored with the &lt;code&gt;TaskScheduled&lt;/code&gt; event in the history. When the engine dispatches that scheduled task again after a crash, a pod eviction, or a worker timeout, your activity receives the same ID. Treat it as an opaque string and don't parse it.&lt;/p&gt;

&lt;p&gt;Here is the payment activity with the task execution ID passed as the idempotency key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChargeCardActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IHttpClientFactory&lt;/span&gt; &lt;span class="n"&gt;httpClientFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChargeCardActivity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WorkflowActivity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChargeRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChargeRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskExecutionId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Charging {Amount} EUR for {CustomerId} with idempotency key {Key}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpClientFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payments"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpRequestMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpMethod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/v1/payments"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonContent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnsureSuccessStatusCode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())!;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replay the eviction scenario with this version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The engine dispatches &lt;code&gt;ChargeCardActivity&lt;/code&gt; with task execution ID &lt;code&gt;a1b2...&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The provider charges 49 EUR and stores the payment under key &lt;code&gt;a1b2...&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The pod is evicted before &lt;code&gt;CompleteActivityTask&lt;/code&gt; reaches the sidecar.&lt;/li&gt;
&lt;li&gt;The workflow restarts, and the engine dispatches the same scheduled task again with task execution ID &lt;code&gt;a1b2...&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The provider recognizes the key and returns the original payment without charging the card.&lt;/li&gt;
&lt;li&gt;The engine records &lt;code&gt;TaskCompleted&lt;/code&gt;, and the workflow moves on to shipping.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your customer pays 49 EUR. The same approach works for any service that accepts a deduplication key, such as email providers, message brokers that support message deduplication IDs, and your own APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Idempotency Key Expires
&lt;/h2&gt;

&lt;p&gt;An idempotency key only helps while the service still remembers it. Many providers clear keys after a fixed period. Stripe, for example, can remove keys once they are at least 24 hours old. Check the documentation of every service you call for its retention period.&lt;/p&gt;

&lt;p&gt;Durable workflows can run for days or weeks, and a second dispatch doesn't always follow the first one quickly. An outage, a long retry backoff, or a suspended workflow that resumes later can all push the second attempt past the retention period. At that point the provider treats the request as new. Some services don't support idempotency keys at all.&lt;/p&gt;

&lt;p&gt;In those cases you need a record you own: a dedupe table in your own database, keyed on the task execution ID. Before doing the work, the activity checks the table. After doing the work, it stores the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendInvoiceActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvoiceDbContext&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IEmailSender&lt;/span&gt; &lt;span class="n"&gt;emailSender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WorkflowActivity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InvoiceRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InvoiceResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InvoiceResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InvoiceRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskExecutionId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;processed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedTasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processed&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvoiceResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;messageId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;emailSender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;To&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedTasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProcessedTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvoiceResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make the task execution ID the primary key of &lt;code&gt;ProcessedTasks&lt;/code&gt;. A unique constraint stops two concurrent attempts, such as a slow worker and its replacement, from both recording a result. Keep rows for longer than your longest-running workflow, and clean up older rows with a scheduled job.&lt;/p&gt;

&lt;p&gt;This version still has a gap. If the process dies between &lt;code&gt;SendAsync&lt;/code&gt; and &lt;code&gt;SaveChangesAsync&lt;/code&gt;, the email goes out twice. To close it, insert a &lt;code&gt;Pending&lt;/code&gt; row before calling the external service and mark it &lt;code&gt;Completed&lt;/code&gt; afterwards. When an attempt finds a &lt;code&gt;Pending&lt;/code&gt; row, it knows a previous attempt may have done the work, and it can check with the provider before trying again. Where the provider supports keys, send the task execution ID to it as well: the provider's key covers the short window, and your table covers everything after the key expires. For more on choosing between keys and this kind of reconciliation, see the &lt;a href="https://www.diagrid.io/faq/durable-execution/should-i-use-idempotency-keys-or-reconciliation-logic-for-external-side-effects-in-agentic" rel="noopener noreferrer"&gt;Diagrid FAQ on idempotency keys and reconciliation logic&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs and Considerations
&lt;/h2&gt;

&lt;p&gt;Check how the task execution ID behaves with retry policies before you rely on it. The .NET SDK documentation describes it as stable across retries, while the activity lifecycle protocol doc says it changes every time the engine retries the activity. For the payment timeout case, that difference decides whether the provider sees one charge or two. Test it on your Dapr and SDK versions. If you need one key across all retry attempts, build it in the workflow from the instance ID and a step name, for example &lt;code&gt;$"{context.InstanceId}:charge-card"&lt;/code&gt;, and pass it as activity input. Use a different step name for every call, including calls inside a loop.&lt;/p&gt;

&lt;p&gt;Scope each key to one side effect. If an activity both charges a card and sends a receipt, it needs two keys, such as &lt;code&gt;{TaskExecutionId}:charge&lt;/code&gt; and &lt;code&gt;{TaskExecutionId}:receipt&lt;/code&gt;. Splitting the work into two activities is usually cleaner.&lt;/p&gt;

&lt;p&gt;Idempotency has a cost. A dedupe table adds a database round trip to every call and a cleanup job to your operations work. Spend that effort on the activities from the checklist above, not on every activity.&lt;/p&gt;

&lt;p&gt;Don't move side effects into the workflow code to avoid retries. Workflow code replays on every step, so a side effect there runs far more often, and it breaks determinism.&lt;/p&gt;

&lt;p&gt;Finally, log the key in every activity. Idempotency stops the duplicate from reaching your customer, and the logs tell you how often it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Durable execution keeps your workflow moving through crashes and pod evictions, but it does so by running activities at least once. The Dapr workflow protocol makes the reason visible: the engine only knows an activity finished once a TaskCompleted event lands in the history, so any failure in between leads to a second dispatch. The fix belongs in your activities. Find the steps with side effects, pass the task execution ID as an idempotency key to the services that support it, and back it with a dedupe table when the downstream service forgets keys too soon or doesn't support them at all. Your 49 EUR charge then stays 49 EUR.&lt;/p&gt;

&lt;p&gt;To build a solid foundation in the Dapr Workflow programming model, including activity retries and error handling, take the free &lt;a href="https://www.diagrid.io/university/workflow" rel="noopener noreferrer"&gt;Dapr Workflow track on Dapr University&lt;/a&gt;. If you prefer video, &lt;a href="https://www.diagrid.io/videos/failure-is-not-an-option-durable-execution-dapr-2" rel="noopener noreferrer"&gt;this session on durable execution with Dapr&lt;/a&gt; covers how replay and history work.&lt;/p&gt;

</description>
      <category>dapr</category>
      <category>architecture</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What's New in the Dapr Dev Dashboard for Local Workflow Debugging</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Mon, 05 Oct 2026 12:39:18 +0000</pubDate>
      <link>https://dev.to/diagrid/whats-new-in-the-dapr-dev-dashboard-for-local-workflow-debugging-2549</link>
      <guid>https://dev.to/diagrid/whats-new-in-the-dapr-dev-dashboard-for-local-workflow-debugging-2549</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;The Diagrid Dapr Dev Dashboard has shipped nine releases since the &lt;a href="https://dev.to/diagrid/a-free-open-source-dev-dashboard-for-local-dapr-development-11ip"&gt;first announcement&lt;/a&gt;, and if you build Dapr Workflow apps on your machine, several of them change how you debug. The Workflows page now loads thousands of instances in milliseconds instead of hanging. A new State page lets you browse, add, and delete the records in your state store. A context-aware Dapr CLI drawer gives you the exact &lt;code&gt;dapr workflow&lt;/code&gt; command for the instance you are looking at, with the IDs already filled in. On top of that, you can publish test messages to a topic, check component secret references, and keep the app list and logs tidy as your dev session grows. This post covers the highlights from v0.0.14 to v0.0.22 and shows how each one helps you find out why a workflow behaves the way it does. You can find an overview of the dashboard on the &lt;a href="https://www.diagrid.io/dev-dashboard" rel="noopener noreferrer"&gt;Dapr Dev Dashboard page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;A Dapr Workflow is long-running and event-sourced. Its state lives in your state store as a history of events, so when an instance stalls or returns the wrong output on your machine, the answer is somewhere in that history. Finding it usually means querying the state store by hand, looking up the right CLI flags, or adding log statements and running the workflow again.&lt;/p&gt;

&lt;p&gt;The dashboard already took care of the basics. It finds the apps you start with &lt;code&gt;dapr run&lt;/code&gt;, .NET Aspire, Docker Compose, or Testcontainers, tails their sidecar and app logs, shows workflow event history, and generates component and resiliency YAML from a form. The recent releases go further into the questions you ask while debugging: what did the workflow write, which instances are stuck, and what do you run to fix them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's new
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Workflows page that stays fast on large state stores
&lt;/h3&gt;

&lt;p&gt;Run a load test or leave a fan-out workflow running overnight, and your local state store can easily hold thousands of workflow instances. Earlier versions of the Workflows page slowed to a crawl at that point, because every instance load scanned the store for that instance's keys: a full keyspace &lt;code&gt;SCAN&lt;/code&gt; on Redis, a &lt;code&gt;LIKE&lt;/code&gt; table scan on the SQL backends.&lt;/p&gt;

&lt;p&gt;The dashboard now loads instances the way Dapr loads its own state. It reads the instance's metadata record, builds the history keys from it, and fetches them in one bulk read. The status counts on the tabs are cached per workflow and only reload when that workflow changes, while the workflow detail page always reads fresh data. These are the numbers for 2,000 instances, before and after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Backend&lt;/th&gt;
&lt;th&gt;List page&lt;/th&gt;
&lt;th&gt;Status counts, first load&lt;/th&gt;
&lt;th&gt;Status counts, refresh&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;td&gt;341 ms → 10 ms&lt;/td&gt;
&lt;td&gt;13.7 s → 0.20 s&lt;/td&gt;
&lt;td&gt;15.9 s → 0.03 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;702 ms → 39 ms&lt;/td&gt;
&lt;td&gt;28.6 s → 1.28 s&lt;/td&gt;
&lt;td&gt;29.4 s → 0.10 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;386 ms → 18 ms&lt;/td&gt;
&lt;td&gt;14.9 s → 0.17 s&lt;/td&gt;
&lt;td&gt;14.7 s → 0.02 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB&lt;/td&gt;
&lt;td&gt;200 ms → 7 ms&lt;/td&gt;
&lt;td&gt;8.1 s → 0.28 s&lt;/td&gt;
&lt;td&gt;8.1 s → 0.08 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The page also behaves better while it loads. When you change a status tab, search, or page, the current rows stay on screen with &lt;strong&gt;Updating…&lt;/strong&gt; in the pager until the new results arrive. The tabs show &lt;strong&gt;…&lt;/strong&gt; until their counts are in, instead of a misleading &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9et8badznt1v958zg73.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9et8badznt1v958zg73.png" alt="Workflows page with status tabs and a large list of instances" width="799" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Browse and edit state with the new State page
&lt;/h3&gt;

&lt;p&gt;Workflow activities often read and write application state, and when a workflow produces the wrong result, you want to see what is actually in the store. The new &lt;strong&gt;State&lt;/strong&gt; page lists the records in any connected state store, with the key, app prefix, value preview, size, version (etag), and TTL for each one. You can switch between stores from the page header and expand a row to see the full value, with a copy button. The dashboard opens Redis, PostgreSQL, SQLite, and MongoDB stores directly.&lt;/p&gt;

&lt;p&gt;To find a record, filter by app prefix or search by key. If Dapr stored a value base64-encoded, flip &lt;strong&gt;Decode base64&lt;/strong&gt; to read it. Workflow history and actor state share the keyspace with your app's records, so they are hidden by default. Turn on &lt;strong&gt;Show internal keys&lt;/strong&gt; when you want to look at the raw workflow keys.&lt;/p&gt;

&lt;p&gt;You can also change state. &lt;strong&gt;+ New record&lt;/strong&gt; writes a record under &lt;code&gt;&amp;lt;app-id&amp;gt;||&amp;lt;key&amp;gt;&lt;/code&gt;, the same key shape Dapr uses, which is handy for seeding data a workflow reads. An existing key is refused unless you tick &lt;strong&gt;Overwrite&lt;/strong&gt;. You can delete records one at a time or select several, with a confirmation step before anything is removed.&lt;/p&gt;

&lt;p&gt;Two limits are worth knowing. There is no "last modified" column, because Dapr state components do not expose a modification timestamp. And a store the dashboard cannot open directly, such as an in-memory store inside a Testcontainers app, cannot be browsed, since Dapr's state API has no way to list keys.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5b3r3xyyipo9saroxtj0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5b3r3xyyipo9saroxtj0.png" alt="State page with an expanded record" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Get the right Dapr CLI command for every page
&lt;/h3&gt;

&lt;p&gt;The dashboard is great for looking around, but sometimes you want the terminal, for example to put a command in a script or rerun it after a restart. A collapsible &lt;strong&gt;Dapr CLI&lt;/strong&gt; drawer on the right edge of the dashboard now shows the Dapr CLI commands that match the page you are on.&lt;/p&gt;

&lt;p&gt;On the workflow views, that means &lt;code&gt;dapr workflow list&lt;/code&gt;, &lt;code&gt;history&lt;/code&gt;, &lt;code&gt;suspend&lt;/code&gt;, &lt;code&gt;resume&lt;/code&gt;, &lt;code&gt;terminate&lt;/code&gt;, and &lt;code&gt;purge&lt;/code&gt;, with the app ID and workflow instance ID from the current view already filled in. Every command has its own copy button, so you do not have to look up flags or paste IDs by hand.&lt;/p&gt;

&lt;p&gt;Other pages get their own set: &lt;code&gt;dapr list&lt;/code&gt; on the applications overview, &lt;code&gt;dapr stop --app-id &amp;lt;id&amp;gt;&lt;/code&gt; on an app's detail page, &lt;code&gt;dapr scheduler&lt;/code&gt; commands on actors, and &lt;code&gt;dapr publish&lt;/code&gt; on subscriptions. The commands target self-hosted mode, so you will not find the Kubernetes &lt;code&gt;-k&lt;/code&gt; variants here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hx912b3hjr1bh4xiy14.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1hx912b3hjr1bh4xiy14.png" alt="Workflow detail page with the Dapr CLI drawer open" width="800" height="548"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Publish test messages from the Subscriptions page
&lt;/h3&gt;

&lt;p&gt;If a message on a topic starts your workflow, testing it used to mean writing a small client or reaching for &lt;code&gt;curl&lt;/code&gt;. Each row on the &lt;strong&gt;Subscriptions&lt;/strong&gt; page now has a &lt;strong&gt;Publish&lt;/strong&gt; button that sends a real message to the topic through the app's own sidecar. You edit the JSON or text payload, choose a content type, and optionally set &lt;code&gt;ttlInSeconds&lt;/code&gt; or &lt;code&gt;rawPayload&lt;/code&gt; under &lt;strong&gt;Advanced&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When the publish succeeds, you get a direct link to the app's logs, so you can watch the message get handled. If daprd rejects it, for example because the pub/sub component is unknown, the error shows up inline in the dialog.&lt;/p&gt;

&lt;p&gt;The page also tells you more about each subscription. A &lt;strong&gt;Type&lt;/strong&gt; column shows whether it is declarative or programmatic, and subscriptions with several routing rules expand to show each rule's match expression and path.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd6nmn3bavf3jrqt5d8mr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd6nmn3bavf3jrqt5d8mr.png" alt="Publish dialog on the Subscriptions page" width="799" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  See which component secrets resolve
&lt;/h3&gt;

&lt;p&gt;Your workflow state store is a Dapr component, and if its password comes from a secret store, one typo in a &lt;code&gt;secretKeyRef&lt;/code&gt; is enough to break it. Until now, a component with a missing secret looked exactly like a working one. The dashboard now resolves &lt;code&gt;secretKeyRef&lt;/code&gt; values against the &lt;code&gt;secretstores.local.file&lt;/code&gt; and &lt;code&gt;secretstores.local.env&lt;/code&gt; stores the same way daprd does, including nested keys and custom separators. &lt;code&gt;envRef&lt;/code&gt; is supported too.&lt;/p&gt;

&lt;p&gt;A component's detail page has a new &lt;strong&gt;Secret references&lt;/strong&gt; panel. For each field it shows the store, the key, and a status, and when resolution fails, the exact file path it tried. Values stay masked; &lt;strong&gt;Reveal&lt;/strong&gt; shows one field at a time and masks it again after 30 seconds. The Components list marks any component with an unresolved reference with a warning sign. When a state store cannot connect because of a secret, the State page names that secret instead of showing a vague connection error.&lt;/p&gt;

&lt;p&gt;Non-local secret stores, such as HashiCorp Vault or Azure Key Vault, are listed but never read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cleaner logs and app list during long dev sessions
&lt;/h3&gt;

&lt;p&gt;On the &lt;strong&gt;Logs&lt;/strong&gt; page, choosing what to tail is now a single &lt;strong&gt;Target&lt;/strong&gt; dropdown, grouped into Applications and Control plane. When you view an app, a compact &lt;strong&gt;daprd | app&lt;/strong&gt; toggle lets you show the sidecar logs, the app logs, or both side by side.&lt;/p&gt;

&lt;p&gt;After a few rounds of starting and stopping apps, the Applications list fills up with stopped entries. &lt;strong&gt;Clear inactive&lt;/strong&gt; removes all fully stopped apps in one click, and each stopped row has its own &lt;strong&gt;Remove&lt;/strong&gt; action. Removing an app only hides it from the list; if it starts again, it reappears. &lt;strong&gt;Restart&lt;/strong&gt; and &lt;strong&gt;Start&lt;/strong&gt; now only appear for Docker Compose apps, where they work reliably. For &lt;code&gt;dapr run&lt;/code&gt; apps you get &lt;strong&gt;Stop&lt;/strong&gt;, with a hint to restart from your terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to get started
&lt;/h2&gt;

&lt;p&gt;The dashboard is a single self-contained binary with no runtime dependencies, available for macOS and Linux (amd64 and arm64) and Windows (amd64). Install it with the one-liner for your OS.&lt;/p&gt;

&lt;p&gt;macOS or Linux:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; https://raw.githubusercontent.com/diagridio/dev-dashboard/main/scripts/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Windows (PowerShell):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;iwr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-useb&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://raw.githubusercontent.com/diagridio/dev-dashboard/main/scripts/install.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the dashboard next to your running Dapr apps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;diagrid-dev-dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It serves the UI on &lt;code&gt;http://localhost:9090&lt;/code&gt; and opens your browser. If no Dapr apps are running yet, the Applications page points you to the &lt;a href="https://github.com/dapr/quickstarts" rel="noopener noreferrer"&gt;Dapr Quickstarts&lt;/a&gt; so you have something to try it on. The &lt;a href="https://github.com/diagridio/dev-dashboard" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt; has the full README and the release notes for every version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Most of the recent work on the Dapr Dev Dashboard targets the questions you ask while debugging a workflow on your machine: what state did it write, which instances are stuck, and which command gets them moving again. A faster Workflows page, the new State page, and the Dapr CLI drawer answer those questions from your browser. You can also publish a test message from the subscription it targets, and spot a missing secret before it shows up as a vague connection error.&lt;/p&gt;

&lt;p&gt;Install the dashboard with the one-liner for your OS, &lt;code&gt;go install&lt;/code&gt;, or the container image from the &lt;a href="https://github.com/diagridio/dev-dashboard" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;, and run &lt;code&gt;diagrid-dev-dashboard&lt;/code&gt; next to your Dapr apps. For an overview of everything it can do, visit the &lt;a href="https://www.diagrid.io/dev-dashboard" rel="noopener noreferrer"&gt;Dapr Dev Dashboard page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Once the dashboard is open, try the Konami code: type ↑ ↑ ↓ ↓ ← → ← → B A in your browser and see what appears. There is a fresh challenge every day, and you can share your best run with an execution ID so others can watch it back.&lt;/p&gt;

</description>
      <category>dapr</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Tiniest Durable Agent I Can Write</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Mon, 28 Sep 2026 07:11:56 +0000</pubDate>
      <link>https://dev.to/diagrid/the-tiniest-durable-agent-i-can-write-jd6</link>
      <guid>https://dev.to/diagrid/the-tiniest-durable-agent-i-can-write-jd6</guid>
      <description>&lt;p&gt;"Durable execution" sounds like something you adopt after a six-week platform evaluation. It isn't. Here's an agent that survives being killed, in roughly twenty lines of Python.&lt;/p&gt;

&lt;p&gt;The trick is that durability isn't a feature you code – it's a property of where the agent's loop runs. With Dapr Agents, the loop runs as a durable workflow, so every LLM call and tool call is journaled automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dapr_agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DurableAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_flights&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Search available flights.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;flights_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;book_flight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flight_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Book a flight. Real money involved.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;flights_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flight_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DurableAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TravelBuddy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Travel planning assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Find options first. Confirm before booking.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;search_flights&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;book_flight&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. Now the fun part – try to break it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ask it to plan a trip. Watch it call search_flights.&lt;/li&gt;
&lt;li&gt;Kill the process while it's mid-task. Not gracefully, kill the entire process.&lt;/li&gt;
&lt;li&gt;Start it again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent picks up the same task where it left off. &lt;br&gt;
The search results it already gathered come back from the workflow's execution history instead of being fetched (and paid for) again. And if the crash happened around book_flight, the journal knows whether that step confirmed – so recovery doesn't book a second seat. LLM calls are slow, costly, and non-deterministic; re-running everything after a crash burns money and can change the answer. Replaying from history does neither.&lt;/p&gt;

&lt;p&gt;Under the hood this is a durable workflow – the same durable execution machinery you'd use for order processing or payment flows, wrapped around an agent loop. No extra database to run: state goes to whatever Dapr state store you configure (Redis locally, Postgres or others in production).&lt;/p&gt;

&lt;p&gt;Honest caveats, as always: the agent's reasoning is only as repeatable as your model, tools with side effects still deserve idempotency keys for the truly paranoid path, and you should read up on workflow versioning before changing an agent that has long-running tasks in flight.&lt;/p&gt;

&lt;p&gt;The longer version of this, with the full runnable sample, lives in &lt;a href="https://www.diagrid.io/blog/the-tiniest-durable-agent" rel="noopener noreferrer"&gt;The Tiniest Durable Agent&lt;/a&gt;. If you want the structured path, the &lt;a href="https://www.diagrid.io/university/dapr-agents" rel="noopener noreferrer"&gt;Dapr Agents track&lt;/a&gt; at Dapr University is free, and the &lt;a href="https://docs.diagrid.io/getting-started/quickstarts/" rel="noopener noreferrer"&gt;quickstarts&lt;/a&gt; get you a running example in a few minutes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>dapr</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Your LangGraph Agent Survived the Demo. Will It Survive a Deploy?</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:54:28 +0000</pubDate>
      <link>https://dev.to/diagrid/your-langgraph-agent-survived-the-demo-will-it-survive-a-deploy-3m9p</link>
      <guid>https://dev.to/diagrid/your-langgraph-agent-survived-the-demo-will-it-survive-a-deploy-3m9p</guid>
      <description>&lt;p&gt;The LangGraph checkpointer is good. I want to say that first, because what follows reads like a complaint and it isn't one. Thread-level persistence, time travel, human-in-the-loop pauses: those work, and they work well.&lt;/p&gt;

&lt;p&gt;There's one failure mode it doesn't cover, and it's the one you hit in production. The process dies after a side effect but before the checkpoint that records it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the difference between checkpointing and durable execution?
&lt;/h2&gt;

&lt;p&gt;A checkpoint is a snapshot of agent state, written once a step has finished. Durable execution keeps a journal of every step as it runs, and the runtime, not your code, decides what completed.&lt;/p&gt;

&lt;p&gt;Here is where that matters. A graph node calls an external API. It files a Jira ticket, or it charges a card. The call succeeds. Then the pod catches a SIGKILL during a rolling deploy, before the checkpoint write lands.&lt;/p&gt;

&lt;p&gt;A checkpointer restores the last checkpoint it has, which is the one from before the node ran. So the agent runs the node again, and now there are two tickets and two charges.&lt;/p&gt;

&lt;p&gt;Durable execution sees an activity in the journal that started and never confirmed, and it can tell you that's what it is. The steps that did confirm don't run twice. The workflow resumes at the one that didn't.&lt;/p&gt;

&lt;p&gt;That's the part of AI workflow orchestration nobody demos. Drawing the boxes and arrows is easy. The runtime underneath has to answer "what already happened?" at any point, and that is a harder thing to build than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping LangGraph, adding durability
&lt;/h2&gt;

&lt;p&gt;You don't have to pick one. LangGraph stays the brain, and each side-effecting step moves inside a durable activity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before: the node does the side effect directly
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_ticket_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jira&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# unprotected side effect
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# after: the node schedules a durable activity
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_ticket_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ticket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;create_ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same graph, same prompts. The difference is that every step now gets journaled, retried with backoff, and resumed after a crash rather than replayed from scratch. That applies to your LLM calls too, so a crash doesn't re-spend the tokens you already burned on steps one through six.&lt;/p&gt;

&lt;p&gt;Caveat where it's due, because durable execution asks for things too. Your workflow code has to be deterministic, which rules out random branching outside activities. And you need to think about versioning before you change a workflow that has instances in flight. So there's a cost. I'd pay it the moment an agent starts touching anything with an invoice attached.&lt;/p&gt;

&lt;p&gt;We've written up running &lt;a href="https://www.diagrid.io/solutions/langgraph-production" rel="noopener noreferrer"&gt;LangGraph in production with durable execution&lt;/a&gt; at more length, and there's a &lt;a href="https://www.diagrid.io/blog/checkpoints-are-not-durable-execution-why-langgraph-crewai-google-adk-and-others-fall-short-for-production-agent-workflows" rel="noopener noreferrer"&gt;longer argument for why checkpoints aren't durable execution&lt;/a&gt; if you want to know more of the details.&lt;/p&gt;

</description>
      <category>langgraph</category>
      <category>ai</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>Run the Test You'd Rather Skip: Kill Your Agent Mid-Payment</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:37:29 +0000</pubDate>
      <link>https://dev.to/diagrid/run-the-test-youd-rather-skip-kill-your-agent-mid-payment-30h5</link>
      <guid>https://dev.to/diagrid/run-the-test-youd-rather-skip-kill-your-agent-mid-payment-30h5</guid>
      <description>&lt;p&gt;Your agent calls a payment API. The process dies right after the charge succeeds and before anything gets written down. What happens when it restarts?&lt;/p&gt;

&lt;p&gt;If the answer is "it retries from the top," you have a problem. "Retry the tool call" sounds harmless until the tool issues a refund, or a charge. Then you have billed a customer twice, and no amount of prompt engineering will explain that to them.&lt;/p&gt;

&lt;p&gt;Most agent code handles the happy path and the visible error path, the one where the API returns a 500 and you try again. The path nobody tests is the invisible one, where the side effect succeeded and then the process disappeared before recording it. Deployments cause this. So do OOM kills, and spot instances do it on a schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  What durable execution does
&lt;/h2&gt;

&lt;p&gt;Durable execution records every completed step in a durable log as the workflow runs. When the process comes back, the runtime replays the log: steps that already completed return their recorded results instantly, and execution resumes at the first step that never finished. Your charge happens once, no matter how many times the process dies.&lt;/p&gt;

&lt;p&gt;With Dapr Workflow in Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dapr.ext.workflow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;

&lt;span class="n"&gt;wfr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WorkflowRuntime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@wfr.activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge_card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;charge_card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# the side effect lives in an activity
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nd"&gt;@wfr.activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_receipt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_receipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WorkflowActivityContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nd"&gt;@wfr.workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_flow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;order_flow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DaprWorkflowContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;charge_card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;receipt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;send_receipt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;wfr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DaprWorkflowClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;instance_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule_new_workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;order_flow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4200&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_workflow_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;wfr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run the test you would rather skip:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start the workflow.&lt;/li&gt;
&lt;li&gt;Kill the process the moment &lt;code&gt;charge_card&lt;/code&gt; completes. It has to be an unclean kill, so the app gets no chance to shut down gracefully: &lt;code&gt;kill -9 &amp;lt;pid&amp;gt;&lt;/code&gt; on macOS or Linux, &lt;code&gt;Stop-Process -Id &amp;lt;pid&amp;gt; -Force&lt;/code&gt; in PowerShell on Windows, or &lt;code&gt;docker kill &amp;lt;container&amp;gt;&lt;/code&gt; if you are running it in a container.&lt;/li&gt;
&lt;li&gt;Restart with only &lt;code&gt;wfr.start()&lt;/code&gt; running, and watch the workflow resume at &lt;code&gt;send_receipt&lt;/code&gt;, with the charge result restored from history instead of re-executed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Durable execution needs idempotent tools
&lt;/h2&gt;

&lt;p&gt;One caveat, and it is the reason step 2 is interesting. The replay only skips a step once that step's completion has been written down. If the process dies in the gap between the charge returning and the completion being recorded, the activity runs again on recovery. So if your payment provider cannot tell two identical requests apart, pass an idempotency key too. Idempotency means a request can be repeated safely without repeating its effect, and that is the half durable execution cannot do for you.&lt;/p&gt;

&lt;p&gt;If you want to go deeper, read this post about &lt;a href="https://www.diagrid.io/blog/the-tiniest-durable-agent" rel="noopener noreferrer"&gt;The Tiniest Durable Agent&lt;/a&gt;, and there is a free, self-paced &lt;a href="https://www.diagrid.io/university/dapr-workflow" rel="noopener noreferrer"&gt;Dapr Workflow course at Dapr University&lt;/a&gt; that covers chaining, fan-out/fan-in, durable timers, and human-in-the-loop in depth.&lt;/p&gt;

&lt;p&gt;For a developer the benefit is code you don't have to write. The workflow above reads top to bottom like ordinary Python, and the state machine, checkpoint table and "did I already charge this card?" column that would normally sit behind it are abstracted away, because the runtime manages that state instead. Recovery stops being something you design per tool call. When a run does go wrong, the history shows which step executed and what it returned, so you debug from a record rather than a guess.&lt;/p&gt;

&lt;p&gt;So: fail the action at the worst possible moment, then check whether your system recovered and whether you can prove what it did. It takes about ten minutes, and you would rather learn the answer now than from the customer you charged twice.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>dapr</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>10 LangGraph Alternatives for Building Agentic Workflows in 2026</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Sat, 08 Aug 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/diagrid/10-langgraph-alternatives-for-building-agentic-workflows-in-2026-261k</link>
      <guid>https://dev.to/diagrid/10-langgraph-alternatives-for-building-agentic-workflows-in-2026-261k</guid>
      <description>&lt;p&gt;&lt;a href="https://www.langchain.com/langgraph" rel="noopener noreferrer"&gt;LangGraph&lt;/a&gt; is a good answer when your agent naturally looks like a graph. It gets harder to defend when your team does not want that graph to become the shape of the whole application.&lt;/p&gt;

&lt;p&gt;None of the alternatives below are LangGraph clones. Each one asks you to think about agents differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want a smaller abstraction
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;a href="https://developers.openai.com/api/docs/guides/agents" rel="noopener noreferrer"&gt;OpenAI Agents SDK&lt;/a&gt; gives you agents, tools, handoffs, guardrails, sessions, and tracing, without asking you to draw an explicit graph for every application.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pydantic.dev/docs/ai/overview/" rel="noopener noreferrer"&gt;Pydantic AI&lt;/a&gt; is worth a look if you write typed Python services. You get structured outputs, validation, and dependency injection.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://strandsagents.com/" rel="noopener noreferrer"&gt;Strands Agents&lt;/a&gt; runs a model-driven loop with tools and keeps the developer experience short.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you want multi-agent concepts first
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://crewai.com/" rel="noopener noreferrer"&gt;CrewAI&lt;/a&gt; models role-based agents, tasks, and crews. When collaboration is the main thing you are describing, that can read better than wiring nodes together.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/en-us/agent-framework/overview/?pivots=programming-language-csharp" rel="noopener noreferrer"&gt;Microsoft Agent Framework&lt;/a&gt; deserves a place on the shortlist if your team is Microsoft-oriented and you are building agents and multi-agent workflows across .NET and Python.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://adk.dev/" rel="noopener noreferrer"&gt;Google ADK&lt;/a&gt; supports modular agents, tools, and composition, and it lines up with Google's models and cloud.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you want distributed-systems primitives
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://docs.dapr.io/developing-ai/dapr-agents/" rel="noopener noreferrer"&gt;Dapr Agents&lt;/a&gt; puts agent development on top of Dapr's state, messaging, service invocation, and workflow foundation.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://restate.dev/" rel="noopener noreferrer"&gt;Restate&lt;/a&gt; is not only an agent framework. It is a durable application runtime that can wrap your agent logic and your SDKs. Look at it when resilient execution matters more to you than taking on another high-level agent abstraction.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you want long-horizon or provider-specific tooling
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://docs.langchain.com/oss/python/deepagents/overview" rel="noopener noreferrer"&gt;Deep Agents&lt;/a&gt; aims at longer and more complex work through planning, sub-agents, and context-management patterns.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/agent-sdk/overview" rel="noopener noreferrer"&gt;The Claude Agent SDK&lt;/a&gt; fits teams building tool-rich agents around Claude's ecosystem and its coding-agent patterns.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Framework choice is not runtime choice
&lt;/h2&gt;

&lt;p&gt;Some of these change how you author the agent. Others change how the agent survives failure. The two decisions are related, but they are not the same decision, and it is easy to shop for one while thinking you are solving the other.&lt;/p&gt;

&lt;p&gt;If you like LangGraph's programming model and what you actually need is production durability, you may not have to replace LangGraph at all. &lt;a href="https://www.diagrid.io/catalyst" rel="noopener noreferrer"&gt;Diagrid Catalyst&lt;/a&gt;, for example, ships a LangGraph runner as part of its bring-your-own-framework approach, and other durable runtimes integrate with agent SDKs too.&lt;/p&gt;

&lt;p&gt;My short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stay on LangGraph when explicit state and graph control are the strengths you want&lt;/li&gt;
&lt;li&gt;pick CrewAI when roles and collaboration match the problem&lt;/li&gt;
&lt;li&gt;pick the OpenAI Agents SDK or Pydantic AI when you want fewer orchestration concepts&lt;/li&gt;
&lt;li&gt;pick an ecosystem-native framework when organizational alignment matters&lt;/li&gt;
&lt;li&gt;add a durable execution layer when the real problem is recovery rather than authoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the one whose mental model your team can still explain a few months after the demo has become a production service. That test tells you more than how new the framework is.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>langchain</category>
    </item>
    <item>
      <title>Inngest or Diagrid Catalyst: From a TypeScript AI Workflow to an Enterprise Agent Platform</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/diagrid/inngest-or-diagrid-catalyst-from-a-typescript-ai-workflow-to-an-enterprise-agent-platform-54p0</link>
      <guid>https://dev.to/diagrid/inngest-or-diagrid-catalyst-from-a-typescript-ai-workflow-to-an-enterprise-agent-platform-54p0</guid>
      <description>&lt;p&gt;A five-person team usually picks the infrastructure it never has to think about, and that is the right call. It stops being the right call on the day the first enterprise customer asks where the agent runs and who is allowed to authorize its tools.&lt;/p&gt;

&lt;p&gt;At prototype stage, Inngest is easy to understand. You write functions in TypeScript, Python, or Go, split durable work into steps, and let the platform deal with state, retries, flow control, and observability. Its current durable-agent model uses step-level memoization, so completed LLM calls and tool invocations are not repeated during a replay. It can also wait for events and invoke sub-agents.&lt;/p&gt;

&lt;p&gt;That suits a web team stretching background jobs into longer AI workflows. The agent stays next to the application code, and there is not much infrastructure to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the decision changes
&lt;/h2&gt;

&lt;p&gt;The architecture conversation usually shifts in stages.&lt;/p&gt;

&lt;p&gt;First you ship the feature. A TypeScript workflow calls an LLM, enriches some data, and writes a result. Developer experience decides the winner.&lt;/p&gt;

&lt;p&gt;Then you have to survive failure. Tool calls need to resume at the right step. Inngest and Catalyst both handle that, through different execution models.&lt;/p&gt;

&lt;p&gt;Then a second and a third agent stack show up. A Python team picks LangGraph, a .NET team picks Microsoft Agent Framework, and another group uses Google ADK. Catalyst's bring-your-own-framework model starts to matter here, because the platform standard can sit below those framework choices instead of competing with them.&lt;/p&gt;

&lt;p&gt;Then you go through enterprise review. Customers ask for workload identity, deny-by-default access, private deployment, MCP governance, and evidence of what each agent did. That is where Catalyst's wider governance model can justify running a larger platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would compare
&lt;/h2&gt;

&lt;p&gt;Retries and dashboards are the least interesting part of this comparison. I would look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which languages and agent frameworks are supported&lt;/li&gt;
&lt;li&gt;what happens to completed steps and side effects during recovery&lt;/li&gt;
&lt;li&gt;who operates the workers, the state, and the execution plane&lt;/li&gt;
&lt;li&gt;how agent-to-tool access is authenticated and authorized&lt;/li&gt;
&lt;li&gt;which self-hosted, private, or air-gapped topologies exist&lt;/li&gt;
&lt;li&gt;how several teams share one platform without sharing permissions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inngest can stay the best choice for developer-first durable functions, background jobs, and AI workflows, as long as its language and deployment model fit the team. Catalyst fits better once the requirement has grown from "make this function durable" into "run and govern many kinds of agents across the enterprise."&lt;/p&gt;

&lt;p&gt;Both mistakes are common, and they are the same mistake pointed in opposite directions: buying the stage-four platform while you are still at stage one, and refusing to reopen a stage-one decision after the organization around it has changed. Let the architecture follow the risk you are actually carrying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/getting-started/quickstarts/ai-agents/?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid Catalyst Quickstarts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/concepts/ai-agents/?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid agent docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.inngest.com/docs/learn/durable-agents" rel="noopener noreferrer"&gt;Inngest durable agents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>Restate or Diagrid Catalyst for Durable Tool Calls?</title>
      <dc:creator>Marc Duiker</dc:creator>
      <pubDate>Thu, 06 Aug 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/diagrid/restate-or-diagrid-catalyst-for-durable-tool-calls-36b8</link>
      <guid>https://dev.to/diagrid/restate-or-diagrid-catalyst-for-durable-tool-calls-36b8</guid>
      <description>&lt;p&gt;"Retry the tool call" sounds harmless until the tool issues a refund. Then durability, identity, and evidence stop being three concerns owned by three different teams and turn into one incident.&lt;/p&gt;

&lt;p&gt;Take a support system built out of several agents. A triage agent classifies the ticket. An account agent retrieves customer state. A policy agent decides whether a refund is allowed. A human approves the recommendation. An action agent calls the refund tool. Then the process loses its connection before the response is recorded.&lt;/p&gt;

&lt;p&gt;Retrying is the easy part. What matters is whether the platform and your own code can work out what already happened, without sending a second refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restate: durable code and journaled steps
&lt;/h2&gt;

&lt;p&gt;Restate is a runtime that sits in front of your services and records execution in a journal. Its AI documentation shows LLM calls and tool executions wrapped as durable steps. After a crash, completed results are replayed from the journal and execution continues from the first step that never finished. Restate supports services, virtual objects, and workflows, and runs through Restate Cloud or a server you host yourself.&lt;/p&gt;

&lt;p&gt;That is a good model if you want resilient functions and services with built-in state, reliable communication, and durable execution written into ordinary code. It also integrates with agent SDKs rather than asking you to adopt one agent framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catalyst: durable agents plus a shared trust layer
&lt;/h2&gt;

&lt;p&gt;Catalyst also persists workflow activity results and resumes failed executions. Around that it adds App ID-based workload identity, access policies, mTLS-backed communication, MCP governance, and a single operational surface for agents and applications. Diagrid positions verifiable execution, meaning history, lineage, signing, and attestation, as an additional trust layer.&lt;/p&gt;

&lt;p&gt;Once money is involved, a security review will want more than proof that the run recovered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which workload identity invoked the refund tool?&lt;/li&gt;
&lt;li&gt;Which policy authorized the call?&lt;/li&gt;
&lt;li&gt;Can you show that the execution history was not altered?&lt;/li&gt;
&lt;li&gt;Can you revoke access without editing the agent prompt?&lt;/li&gt;
&lt;li&gt;Does the same control apply across cloud, on-premises, and isolated deployments?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where each one fits
&lt;/h2&gt;

&lt;p&gt;Restate is the better fit when what you need is a lightweight durable runtime for services, stateful entities, workflows, and agents, and you want to express resilience directly in normal application code.&lt;/p&gt;

&lt;p&gt;Catalyst is the better fit when durable execution has to arrive together with organization-wide agent identity, policy-controlled tool access, verifiable evidence, and governance across several frameworks and deployment environments.&lt;/p&gt;

&lt;p&gt;Neither one takes your application off the hook once the outcome is unknown. A payment or refund integration still needs idempotency keys, reconciliation, and explicit compensation logic. Durable execution preserves progress. It cannot tell you whether an external system committed a request when the response was lost.&lt;/p&gt;

&lt;p&gt;So run the test you would rather skip. Fail the action at the worst possible moment, then check whether it recovered, whether you can prove who did what, and whether the same refund can go out twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.restate.dev/ai/patterns/durable-agents" rel="noopener noreferrer"&gt;Restate durable agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.restate.dev/use-cases/ai-agents" rel="noopener noreferrer"&gt;Restate AI agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.diagrid.io/catalyst?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid Catalyst&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.diagrid.io/concepts/ai-agents?utm_source=dev-to&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;Diagrid AI agents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
