<?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: t-obara</title>
    <description>The latest articles on DEV Community by t-obara (@obataka).</description>
    <link>https://dev.to/obataka</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%2Fuser%2Fprofile_image%2F4018545%2Ffa8f2eae-c7de-47d8-bb6b-d418c1202294.jpg</url>
      <title>DEV Community: t-obara</title>
      <link>https://dev.to/obataka</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/obataka"/>
    <language>en</language>
    <item>
      <title>Postmortem: Silent Data Loss in an In-Process Vector Store, and the Case for Durable Execution</title>
      <dc:creator>t-obara</dc:creator>
      <pubDate>Tue, 21 Jul 2026 01:47:00 +0000</pubDate>
      <link>https://dev.to/obataka/postmortem-silent-data-loss-in-an-in-process-vector-store-and-the-case-for-durable-execution-16i2</link>
      <guid>https://dev.to/obataka/postmortem-silent-data-loss-in-an-in-process-vector-store-and-the-case-for-durable-execution-16i2</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bug:&lt;/strong&gt; Calling &lt;code&gt;optimize()&lt;/code&gt; on a ZVec &lt;code&gt;Collection&lt;/code&gt; that had recovered from an unclean process termination silently discarded the documents restored by WAL replay. The call raised no exception, and &lt;code&gt;stats()&lt;/code&gt; on the same handle reported &lt;code&gt;index_completeness: 1.0&lt;/code&gt; — a false signal of success. The loss only became visible when the collection was reopened from a separate process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impact:&lt;/strong&gt; In a minimal repro, &lt;code&gt;doc_count&lt;/code&gt; regressed from 150 to 0 after &lt;code&gt;optimize()&lt;/code&gt; on a crash-recovered collection with no prior &lt;code&gt;flush()&lt;/code&gt; calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix latency:&lt;/strong&gt; 54 hours 55 minutes from issue filing to merged fix (&lt;code&gt;alibaba/zvec&lt;/code&gt; &lt;a href="https://github.com/alibaba/zvec/issues/594" rel="noopener noreferrer"&gt;#594&lt;/a&gt; → &lt;a href="https://github.com/alibaba/zvec/pull/600" rel="noopener noreferrer"&gt;#600&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root cause:&lt;/strong&gt; A WAL handle lifecycle bug — segment recovery replayed the WAL into a local handle and closed it without reattaching it to the segment, so &lt;code&gt;optimize()&lt;/code&gt; treated the writing segment as having no WAL records and skipped persisting the recovered data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core argument:&lt;/strong&gt; Fixing this one bug and preventing this &lt;em&gt;class&lt;/em&gt; of bug are two different problems. The patch closes a specific code path; it does not change the fact that the storage engine's durability guarantees are implicit and handle-dependent. The second half of this post looks at what changes when state ownership is pushed to a system whose recovery model has no "last flush boundary" at all — a durable execution / event-sourcing layer such as Temporal.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Background: Why an In-Process Vector Store Has to Earn Its Durability Claims
&lt;/h2&gt;

&lt;p&gt;ZVec is an in-process vector database: no server process, no connection string, storage backed by write-ahead logging (WAL) on the local filesystem. That simplicity is attractive for embedding retrieval directly inside a workflow orchestrator, where the vector store is expected to behave like any other durable resource an orchestrator's Activities touch.&lt;/p&gt;

&lt;p&gt;For that use case, "crash-durable" is not a nice-to-have — it is a precondition for adoption. If a storage engine can silently lose state on process failure, it cannot be trusted as the backing store for anything a workflow engine expects to survive a crash and resume correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Baseline: WAL Replay Works
&lt;/h3&gt;

&lt;p&gt;Before this bug surfaces, the base case is worth establishing, since it is where trust in the system starts. A hard-killed process (&lt;code&gt;os._exit(1)&lt;/code&gt;, equivalent to &lt;code&gt;kill -9&lt;/code&gt;) with unflushed inserts fully recovers on &lt;code&gt;zvec.open()&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;crashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zvec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./zvec_crash_test&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;crashed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# doc_count=150 index_completeness={'embedding': 0.0}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;doc_count&lt;/code&gt; is correct immediately after a crash, purely from automatic WAL replay — no manual recovery call required. &lt;code&gt;index_completeness: 0.0&lt;/code&gt; at this point is not an anomaly; it is the default state of any collection, crashed or not, until &lt;code&gt;optimize()&lt;/code&gt; has been called on it, and &lt;code&gt;query()&lt;/code&gt; still returns correct results while it holds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anatomy of the Silent Loss
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Deceptive Success Signal on the Same Handle
&lt;/h3&gt;

&lt;p&gt;Calling &lt;code&gt;optimize()&lt;/code&gt; on a crash-recovered collection does not raise. Worse, &lt;code&gt;stats()&lt;/code&gt; on the &lt;em&gt;same&lt;/em&gt; handle immediately afterward reports the pre-existing &lt;code&gt;doc_count&lt;/code&gt; unchanged, alongside &lt;code&gt;index_completeness: 1.0&lt;/code&gt; — every observable signal on that handle says the optimize succeeded cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[optimize] before: doc_count=150 index_completeness={'embedding': 0.0}
[optimize] same-handle after optimize(): doc_count=150 index_completeness={'embedding': 1.0}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither the return value nor an exception nor the immediately-following &lt;code&gt;stats()&lt;/code&gt; call exposes the defect. This is the core hazard: a call that completes cleanly and reports a plausible, successful-looking state can still have discarded data.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Loss Surfaces Only Across a Process Boundary
&lt;/h3&gt;

&lt;p&gt;The regression is invisible on the handle that called &lt;code&gt;optimize()&lt;/code&gt; because that handle still holds the in-memory view of the data. It becomes visible only when a &lt;strong&gt;separate process&lt;/strong&gt; reopens the same collection from disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[after-optimize] doc_count=0 index_completeness={'embedding': 0.0}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;doc_count&lt;/code&gt; regresses to whatever was last durably segmented to disk before the crash — in this case, 0, since no &lt;code&gt;flush()&lt;/code&gt; was ever called. The gap between the in-memory state a handle believes is true and the state actually persisted to disk is exactly what &lt;code&gt;optimize()&lt;/code&gt;'s bug exploited.&lt;/p&gt;




&lt;h2&gt;
  
  
  Isolating the Cause: A Control Experiment
&lt;/h2&gt;

&lt;p&gt;A plausible but wrong hypothesis at this point is "&lt;code&gt;optimize()&lt;/code&gt; loses unflushed data." A control experiment rules that out: a collection with unflushed data but &lt;strong&gt;no crash history&lt;/strong&gt; — created, populated, and shut down normally — retains 100% of its documents after &lt;code&gt;optimize()&lt;/code&gt;, unflushed data included.&lt;/p&gt;

&lt;p&gt;The loss is therefore not a property of "unflushed data" in general. It is specific to documents that were recovered via WAL replay from a genuine unclean termination. &lt;code&gt;optimize()&lt;/code&gt;'s index-rebuild path excluded that WAL-replayed data from the rebuilt segment rather than merging it in.&lt;/p&gt;

&lt;p&gt;A second experiment narrowed the loss boundary precisely. With a periodic &lt;code&gt;flush()&lt;/code&gt; cadence — every 100 documents, 551 total inserted — &lt;code&gt;optimize()&lt;/code&gt; on the crash-recovered collection left exactly 501 documents; IDs 501–550, everything inserted after the last &lt;code&gt;flush()&lt;/code&gt; at ID 500, were missing. The loss boundary is exactly the last durably-flushed segment, not "everything since the crash" and not "everything unflushed." A single experiment result is not treated as proof of a causal claim here — the crash-history-vs-no-crash-history comparison is what isolates the trigger.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: Root Cause and Patch Design (PR #600)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Root cause, in one sentence:&lt;/strong&gt; segment recovery replayed WAL records into a &lt;em&gt;local&lt;/em&gt; WAL handle, then closed it without restoring the &lt;em&gt;member&lt;/em&gt; WAL handle on the segment — so &lt;code&gt;optimize()&lt;/code&gt;'s subsequent flush treated the writing segment as having no WAL records and skipped persisting them.&lt;/p&gt;

&lt;p&gt;The patch to &lt;code&gt;src/db/index/segment/segment.cc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;@@ -4212,8 +4212,6 @@&lt;/span&gt; Status SegmentImpl::recover() {
               wal_file_path.c_str());
     return Status::OK();
   }
&lt;span class="gd"&gt;-  AILEGO_DEFER([&amp;amp;]() { recover_wal_file-&amp;gt;close(); });
-
&lt;/span&gt;   std::array&amp;lt;uint64_t, static_cast&amp;lt;size_t&amp;gt;(Operator::DELETE) + 1&amp;gt;
       recovered_doc_count{};
   uint64_t total_recovered_doc_count{0};
&lt;span class="p"&gt;@@ -4297,7 +4295,17 @@&lt;/span&gt; Status SegmentImpl::recover() {
       (size_t)recovered_doc_count[3]   // DELETE
   );
&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-  return Status::OK();
&lt;/span&gt;&lt;span class="gi"&gt;+  if (recover_wal_file-&amp;gt;close() != 0) {
+    return Status::InternalError("Failed to close recovered wal file: ",
+                                 wal_file_path);
+  }
+  recover_wal_file.reset();
+
+  // Keep the recovered WAL attached to the segment. Operations such as
+  // optimize() flush the writing segment before sealing it; without an open
+  // member WAL, flush() treats the recovered memory components as empty and
+  // returns without persisting them.
+  return open_wal_file();
&lt;/span&gt; }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of discarding the recovery WAL handle once replay finishes, &lt;code&gt;recover()&lt;/code&gt; now reopens the segment's member WAL handle (&lt;code&gt;open_wal_file()&lt;/code&gt;) before returning. &lt;code&gt;optimize()&lt;/code&gt;'s subsequent flush step then sees a live WAL attached to the segment and persists the recovered records instead of treating the segment as empty.&lt;/p&gt;

&lt;p&gt;This is a handle-lifecycle bug, not a logic bug in the index-rebuild algorithm itself — which is also why the patch is narrow: three lines removed, roughly a dozen added, confined to the recovery path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Structural Mitigation: Why "Be Careful With &lt;code&gt;optimize()&lt;/code&gt;" Isn't a Strategy
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Patch Closes &lt;em&gt;This&lt;/em&gt; Bug, Not &lt;em&gt;This Class&lt;/em&gt; of Bug
&lt;/h3&gt;

&lt;p&gt;PR #600 closes the specific intersection of crash recovery and &lt;code&gt;optimize()&lt;/code&gt;. It does not change the underlying design: an in-process storage engine that owns its own, implicit durability contract, with no external signal exposed at the time of a call like whether a collection currently carries unreplayed crash-recovery state. The next unknown intersection between recovery and some other operation is a design property, not a one-off accident, and a single patch cannot retire that property.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event-Sourced Replay vs. Segment/WAL Replay
&lt;/h3&gt;

&lt;p&gt;ZVec's WAL-based recovery model has a &lt;em&gt;boundary&lt;/em&gt;: the last segment durably flushed to disk. Everything after that boundary depends on the WAL being correctly replayed and correctly reattached on every code path that might act on it — which is exactly the class of invariant that PR #600 had to repair by hand.&lt;/p&gt;

&lt;p&gt;Durable execution frameworks built on event sourcing, such as Temporal, do not have an equivalent boundary. A Workflow's state is not a periodically-flushed snapshot; it is the deterministic replay of its complete event history — every signal received, every activity result recorded — reconstructed from that history on every recovery. There is no concept of "the last flush point" for the replay to fall behind, because the event history &lt;em&gt;is&lt;/em&gt; the durable state, not a cache of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reference Implementation: Signal-Sourced State With Verified Crash Recovery
&lt;/h3&gt;

&lt;p&gt;A companion Temporal-based agent workflow used elsewhere demonstrates the pattern concretely. A long-running Workflow exposes signal handlers that mutate its in-memory state:&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;@workflow.signal&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_task_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@workflow.signal&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_task_priority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&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="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="nd"&gt;@workflow.signal&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;inject_human_feedback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feedback&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_human_feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every signal is recorded into Temporal's event history as it is received. This is not a proposal — it has an automated resilience test that actually exercises the failure mode: start the Workflow, send signals (inject feedback, change priority), &lt;strong&gt;forcibly kill the worker process&lt;/strong&gt;, restart it, and query the Workflow's state to confirm the signal-driven changes survived the crash intact. The recovery path here is full deterministic replay of the event history against a fresh worker, not a WAL segment reattachment.&lt;/p&gt;

&lt;p&gt;This does not make the underlying storage problem disappear entirely. ZVec still exposes no full-collection enumeration API, so any migration-based recovery workaround depends on the caller already knowing every document ID. That is precisely why, in an architecture where ZVec sits inside a Temporal-orchestrated pipeline, the orchestrator's own durable state — not the storage engine's internal bookkeeping — is the right place to keep the ID ledger of record.&lt;/p&gt;




&lt;h2&gt;
  
  
  Post-Fix Verification: Does the Patch Work as Expected?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Constraint: Not Yet on PyPI
&lt;/h3&gt;

&lt;p&gt;As of this verification pass, &lt;code&gt;pip index versions zvec&lt;/code&gt; still reports &lt;code&gt;0.5.1&lt;/code&gt; as latest — the same version the bug was originally found on. The merge commit containing the fix (&lt;code&gt;e89be98b&lt;/code&gt;) is not included in any published PyPI release. A &lt;code&gt;v0.6.0&lt;/code&gt; git tag exists in the repository, but comparing it against the fix commit shows it is &lt;em&gt;behind&lt;/em&gt; the fix — it was cut before the patch landed. Re-running the original repro script against a released package is therefore not currently possible, and that limitation is stated here rather than worked around.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code-Level Verification
&lt;/h3&gt;

&lt;p&gt;The change described above was confirmed directly against the PR's diff, not inferred from the PR description: the root-cause explanation ("the recovered WAL was not reattached to the segment's member handle") matches the actual patch (&lt;code&gt;recover()&lt;/code&gt; now calls &lt;code&gt;open_wal_file()&lt;/code&gt; before returning, instead of leaving the segment without a live WAL handle).&lt;/p&gt;

&lt;h3&gt;
  
  
  Test-Level Verification
&lt;/h3&gt;

&lt;p&gt;The PR adds a regression test that mirrors the originally reported reproduction almost exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;TEST_F&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CrashRecoveryTest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OptimizeAfterCrashRecoveryPersistsReplayedData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... create collection, crash during insertion ...&lt;/span&gt;

  &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Collection&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir_path_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options_&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;collection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;recovered_doc_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;Stats&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;doc_count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;ASSERT_GT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recovered_doc_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s"&gt;"No documents were recovered from the WAL"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;Optimize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;ASSERT_TRUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;status&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;ASSERT_EQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;Stats&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;doc_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recovered_doc_count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Reopen from a fresh handle and assert nothing was lost.&lt;/span&gt;
  &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;reopened&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Collection&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir_path_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options_&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;ASSERT_EQ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reopened&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;Stats&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;doc_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recovered_doc_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s"&gt;"Optimize discarded documents restored from the WAL"&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;Crash, reopen, &lt;code&gt;optimize()&lt;/code&gt;, reopen again from a fresh handle, assert &lt;code&gt;doc_count&lt;/code&gt; is unchanged — the same crash-then-verify-from-a-fresh-process structure used to originally isolate the bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  CI Evidence: Where the PR Description and the Public Logs Diverge
&lt;/h3&gt;

&lt;p&gt;The PR description states that "the full CI matrix passed on the same commit: Linux, macOS, Windows, Android, iOS, lint, and clang-tidy." Pulling the actual check-runs from the GitHub Actions API tells a more precise story.&lt;/p&gt;

&lt;p&gt;On the PR's own head commit (&lt;code&gt;aaa4aab4&lt;/code&gt;), the Build &amp;amp; Test jobs for every platform, along with &lt;code&gt;lint&lt;/code&gt; and &lt;code&gt;clang-tidy&lt;/code&gt;, show as &lt;strong&gt;skipped&lt;/strong&gt; — consistent with a permission gate that withholds full CI from external-contributor pull requests until a maintainer authorizes it. "The same commit" did not, in fact, run the full matrix.&lt;/p&gt;

&lt;p&gt;What actually ran, and passed, was the &lt;strong&gt;merge commit onto &lt;code&gt;main&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;e89be98b&lt;/code&gt;). Its CI log shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;66/152 Test  #&lt;/span&gt;67: optimize_recovery_test ............................   Passed  354.84 sec
&lt;span class="gp"&gt;109/152 Test  #&lt;/span&gt;68: write_recovery_test ...............................   Passed  1687.70 sec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;write_recovery_test&lt;/code&gt; is the binary that contains the new &lt;code&gt;OptimizeAfterCrashRecoveryPersistsReplayedData&lt;/code&gt; case; it passed on the merge commit, along with the C++ test suite, the Python test suite, &lt;code&gt;lint&lt;/code&gt;, &lt;code&gt;clang-tidy&lt;/code&gt;, and a &lt;code&gt;linux-x64&lt;/code&gt; build. That is solid first-party evidence that the fix behaves as intended.&lt;/p&gt;

&lt;p&gt;It is also worth being precise about what did &lt;em&gt;not&lt;/em&gt; run: the Windows, macOS (multiple variants), iOS, and Android Build &amp;amp; Test jobs remained &lt;strong&gt;skipped&lt;/strong&gt; even after the merge to &lt;code&gt;main&lt;/code&gt;. "Full CI matrix" is therefore accurate for the core Linux test suite and static analysis, but not for the full platform matrix implied by the PR description.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Was Not Done
&lt;/h3&gt;

&lt;p&gt;This verification did not include building ZVec from source and re-running the original Python repro script against the patched code — there is no PyPI release yet to install, and a from-source build was out of scope for this pass. The natural follow-up, once a release beyond &lt;code&gt;0.5.1&lt;/code&gt; ships, is to re-run &lt;code&gt;repro_zvec_optimize_dataloss.py&lt;/code&gt; against the released package and confirm &lt;code&gt;doc_count&lt;/code&gt; no longer regresses to 0.&lt;/p&gt;




&lt;h2&gt;
  
  
  Engineering Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Verify data persistence from a fresh process and a fresh handle, never from the same handle that performed the write — a same-handle read can report a plausible, successful-looking state while the disk-persisted state has already diverged.&lt;/li&gt;
&lt;li&gt;If a storage API cannot distinguish "unflushed" from "crash-recovered" in its return values or metrics, that distinction has to be tracked explicitly by the caller.&lt;/li&gt;
&lt;li&gt;Do not treat a storage engine's internal durability model as a self-sufficient system of record inside an orchestrated pipeline. Treat the orchestration layer's own durable state as the ledger of record instead.&lt;/li&gt;
&lt;li&gt;Do not generalize a single experiment's result into a causal claim ("crash-specific," "a general bug," etc.) without a control experiment to isolate the actual variable.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>python</category>
      <category>zvec</category>
    </item>
    <item>
      <title>The In-Process Vector DB Trap: How ZVec's `optimize()` Can Silently Erase Crash-Recovered Data</title>
      <dc:creator>t-obara</dc:creator>
      <pubDate>Tue, 14 Jul 2026 02:02:51 +0000</pubDate>
      <link>https://dev.to/obataka/the-in-process-vector-db-trap-how-zvecs-optimize-can-silently-erase-crash-recovered-data-1ceh</link>
      <guid>https://dev.to/obataka/the-in-process-vector-db-trap-how-zvecs-optimize-can-silently-erase-crash-recovered-data-1ceh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (July 2026):&lt;/strong&gt; We have published a follow-up postmortem detailing the official fix (PR #600) for this bug and the structural case for durable execution (Temporal). Read the full analysis here: [&lt;a href="https://dev.to/obataka/postmortem-silent-data-loss-in-an-in-process-vector-store-and-the-case-for-durable-execution-16i2"&gt;https://dev.to/obataka/postmortem-silent-data-loss-in-an-in-process-vector-store-and-the-case-for-durable-execution-16i2&lt;/a&gt;]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Embedded, serverless vector databases are having a moment. The pitch is simple: skip the sidecar container, skip the network hop, just call a Python API and get a vector index that lives inside your own process. ZVec (PyPI package &lt;code&gt;zvec&lt;/code&gt;, v0.5.1, built by Alibaba on a C++ core with Python bindings) is one of the newer entrants making that pitch. Opening a collection is genuinely trivial:&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;zvec&lt;/span&gt;

&lt;span class="n"&gt;collection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zvec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_and_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/spike-zvec/data/my_collection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Docker, no connection string, no separate process to keep alive. For a team running retrieval-augmented generation inside an orchestration layer — Temporal Activities, in our case — that simplicity is worth taking seriously. One fewer moving part is one fewer thing that pages you at 3am.&lt;/p&gt;

&lt;p&gt;But "runs inside your process" cuts both ways. If the vector store lives inside the Activity's process, it dies exactly when the Activity's process dies — &lt;code&gt;SIGKILL&lt;/code&gt;, an OOM kill, a host eviction, whatever your orchestrator throws at a worker mid-task. Before trusting any in-process store with durable state, the only honest question is: what does it look like on the other side of a hard crash?&lt;/p&gt;

&lt;p&gt;So we tested it the direct way. We inserted 551 documents into a ZVec collection and, mid-run, killed the process with &lt;code&gt;os._exit(1)&lt;/code&gt; — the Python equivalent of &lt;code&gt;kill -9&lt;/code&gt;, no cleanup handlers, no graceful shutdown. Then we reopened the collection from a fresh process and checked what came back.&lt;/p&gt;

&lt;p&gt;The result was reassuring on the point that matters most: &lt;strong&gt;the document data itself survived intact.&lt;/strong&gt; &lt;code&gt;stats().doc_count&lt;/code&gt; reported exactly 551 on reopen — no loss, no duplication. ZVec's write-ahead log (WAL) replayed automatically on &lt;code&gt;open()&lt;/code&gt;, with no explicit "recover" call required from application code. For raw durability of inserted documents, this held up under a real kill-9 test.&lt;/p&gt;

&lt;p&gt;What did &lt;em&gt;not&lt;/em&gt; look reassuring at first glance was this line from the same &lt;code&gt;stats()&lt;/code&gt; call:&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stats&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;doc_count&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;551&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;index_completeness&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;embedding&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;index_completeness: 0.0&lt;/code&gt;, right after a hard crash, reads like exactly the kind of number that should make you nervous — a half-built index, a corruption signal, something to fix immediately. We treated it that way at first too. It turned out to be the wrong read.&lt;/p&gt;

&lt;p&gt;We reopened three other collections from earlier, entirely uneventful test runs — collections that had never crashed, never been killed, shut down cleanly every time. All three reported the identical &lt;code&gt;index_completeness: 0.0&lt;/code&gt;. The number has nothing to do with crash history. It is simply the default state of &lt;em&gt;any&lt;/em&gt; ZVec collection — pristine or crash-recovered — that has never had &lt;code&gt;optimize()&lt;/code&gt; called on it. And critically, a collection sitting at &lt;code&gt;index_completeness: 0.0&lt;/code&gt; is not degraded in any functional sense: &lt;code&gt;query()&lt;/code&gt; against it returns correct, ranked results. It's an unbuilt index, not a broken one.&lt;/p&gt;

&lt;p&gt;That distinction — "unbuilt" versus "broken" — turns out to matter a great deal, because the natural next move is to reach for &lt;code&gt;optimize()&lt;/code&gt; to build the index and push that number to &lt;code&gt;1.0&lt;/code&gt;. That move is where the real danger in this system lives, and it's the subject of the rest of this piece.&lt;/p&gt;




&lt;h2&gt;
  
  
  Section 2: The Deep Pitfall — Silent Data Loss in &lt;code&gt;optimize()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The instinct, once you've seen &lt;code&gt;index_completeness: 0.0&lt;/code&gt;, is straightforward: call &lt;code&gt;collection.optimize()&lt;/code&gt; and move on.&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;collection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zvec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/spike-zvec/data/crashed_collection&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="c1"&gt;# {'doc_count': 551, 'index_completeness': {'embedding': 0.0}}
&lt;/span&gt;
&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optimize&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="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="c1"&gt;# {'doc_count': 551, 'index_completeness': {'embedding': 1.0}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the same process handle, this looks like a clean repair: &lt;code&gt;doc_count&lt;/code&gt; is still 551, &lt;code&gt;index_completeness&lt;/code&gt; is now &lt;code&gt;1.0&lt;/code&gt;. No exception was raised. Nothing in the return value or the logs suggests anything went wrong.&lt;/p&gt;

&lt;p&gt;Reopen that same collection from a &lt;strong&gt;fresh process&lt;/strong&gt;, though, and a different number comes back. We ran this against a collection that had been killed with &lt;code&gt;os._exit(1)&lt;/code&gt; mid-insert at document #551, under two different flush cadences:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flush cadence before crash&lt;/th&gt;
&lt;th&gt;Docs inserted before crash&lt;/th&gt;
&lt;th&gt;Last durable &lt;code&gt;flush()&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;doc_count&lt;/code&gt; after &lt;code&gt;optimize()&lt;/code&gt;, verified on fresh reopen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;flush()&lt;/code&gt; calls at all&lt;/td&gt;
&lt;td&gt;551&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;flush()&lt;/code&gt; every 100 docs&lt;/td&gt;
&lt;td&gt;551&lt;/td&gt;
&lt;td&gt;doc #500&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;501&lt;/strong&gt; (IDs 501–550 confirmed missing via &lt;code&gt;fetch()&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the first case, every one of the 551 documents that had survived the crash and reappeared correctly after &lt;code&gt;open()&lt;/code&gt; was gone after &lt;code&gt;optimize()&lt;/code&gt; — a complete round-trip from "fully recovered" to "fully lost," with &lt;code&gt;optimize()&lt;/code&gt; as the only operation in between. In the second case, the loss was smaller but followed the same pattern: everything after the last confirmed &lt;code&gt;flush()&lt;/code&gt; disappeared. &lt;code&gt;stats().doc_count&lt;/code&gt; reported 551 correctly right up until the moment &lt;code&gt;optimize()&lt;/code&gt; was called; afterward, the number silently regressed to whatever had last been durably segmented to disk. There is no warning, no exception, no flag in the &lt;code&gt;optimize()&lt;/code&gt; return value — the only way to see the loss is to reopen from a separate process and check, or to &lt;code&gt;fetch()&lt;/code&gt; individual document IDs and find them missing.&lt;/p&gt;

&lt;p&gt;It would be easy — and it was, in fact, our own first conclusion — to generalize this into a blanket rule: "&lt;code&gt;optimize()&lt;/code&gt; discards unflushed data." That framing turned out to be wrong, and the correction matters more than the original finding, because a rule that's too broad leads to over-cautious workarounds (excessive &lt;code&gt;flush()&lt;/code&gt; calls everywhere) that don't actually address the real risk.&lt;/p&gt;

&lt;p&gt;To isolate the real cause, we built a collection with 20 documents inserted and flushed, then 10 more inserted with &lt;strong&gt;no&lt;/strong&gt; flush, then shut the process down &lt;strong&gt;normally&lt;/strong&gt; — no crash, no &lt;code&gt;os._exit()&lt;/code&gt;, just an ordinary exit. Calling &lt;code&gt;optimize()&lt;/code&gt; on that collection preserved all 30 documents, unflushed ones included, verified on a fresh reopen with every ID present. Unflushed data, on its own, is not what &lt;code&gt;optimize()&lt;/code&gt; discards.&lt;/p&gt;

&lt;p&gt;The actual mechanism is narrower and more specific: &lt;strong&gt;&lt;code&gt;optimize()&lt;/code&gt;'s data loss applies only to documents recovered via WAL replay from a genuine crash.&lt;/strong&gt; A collection that has crashed and been auto-recovered on &lt;code&gt;open()&lt;/code&gt; carries that recovered data in a state that &lt;code&gt;optimize()&lt;/code&gt;'s index-rebuild path silently excludes — regardless of whether it happens to be flushed or not, in the crashed case. A collection with unflushed data but no crash history is fine. The determining factor isn't flush state; it's whether the collection has ever been killed mid-write. Nothing in the public API — no field in &lt;code&gt;stats()&lt;/code&gt;, no flag, no exception type — tells you a collection carries this history at read time. The only external evidence is a one-time "crash residue" warning ZVec's C++ layer logs to stderr the &lt;em&gt;first&lt;/em&gt; time such a collection is reopened after the crash; there is no persistent, queryable marker after that.&lt;/p&gt;

&lt;p&gt;For anyone evaluating ZVec as durable storage inside a workflow engine, this is the finding that should change how a recovery path gets written: &lt;code&gt;index_completeness: 0.0&lt;/code&gt; after a crash is not the danger signal it appears to be, and reaching for &lt;code&gt;optimize()&lt;/code&gt; to clear it is precisely the operation that turns a fully-recovered collection into a partially or completely empty one. The next section covers the recovery procedure that was measured to avoid this — reinserting recovered documents into a fresh, crash-free collection before ever calling &lt;code&gt;optimize()&lt;/code&gt; — along with the architectural gap that procedure exposes in ZVec's API surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 3: The Architectural Solution — Fetch, Reinsert, Then Optimize
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;optimize()&lt;/code&gt; can't be trusted against a collection that carries crash history, the practical question is whether there's a path that gets both properties at once: zero data loss &lt;em&gt;and&lt;/em&gt; a fully built index (&lt;code&gt;index_completeness: 1.0&lt;/code&gt;). We tested one, and it held up.&lt;/p&gt;

&lt;p&gt;The idea is to stop treating the crashed collection as something to be repaired in place, and instead treat it as a read-only source to migrate out of:&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;zvec&lt;/span&gt;

&lt;span class="c1"&gt;# STEP 1 — open the crashed collection. This alone triggers WAL replay;
# doc_count comes back correct even though index_completeness is still 0.0.
&lt;/span&gt;&lt;span class="n"&gt;crashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zvec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/spike-zvec/data/crashed_collection&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;crashed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="c1"&gt;# {'doc_count': 551, 'index_completeness': {'embedding': 0.0}}
&lt;/span&gt;
&lt;span class="c1"&gt;# STEP 2 — fetch every document by ID. In testing, all 551/551 were
# retrieved with zero missing.
&lt;/span&gt;&lt;span class="n"&gt;all_ids&lt;/span&gt; &lt;span class="o"&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;i&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;551&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crashed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;include_vector&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="k"&gt;assert&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;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;551&lt;/span&gt;

&lt;span class="c1"&gt;# STEP 3 — create a brand-new collection. It has no crash history,
# so it is not subject to the exclusion behavior from Section 2.
&lt;/span&gt;&lt;span class="n"&gt;recovered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zvec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_and_open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/spike-zvec/data/recovered_collection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# STEP 4 — reinsert through the normal insert() path, not a raw copy.
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;recovered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;recovered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# STEP 5 — only now, on the crash-free collection, call optimize().
&lt;/span&gt;&lt;span class="n"&gt;recovered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optimize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified on a fresh process reopen of &lt;code&gt;recovered_collection&lt;/code&gt;: &lt;code&gt;doc_count&lt;/code&gt; came back as 551 — no loss — and &lt;code&gt;index_completeness&lt;/code&gt; came back as &lt;code&gt;{'embedding': 1.0}&lt;/code&gt;. Sampled vectors (IDs at both ends of the range) matched the originals byte-for-byte via &lt;code&gt;np.allclose&lt;/code&gt;, and &lt;code&gt;query()&lt;/code&gt; against the collection returned correctly ranked results. This is the only procedure in the entire investigation that achieved full index completeness and zero data loss at the same time.&lt;/p&gt;

&lt;p&gt;The mechanism is exactly what Section 2 predicted: &lt;code&gt;recovered_collection&lt;/code&gt; was built entirely through ordinary &lt;code&gt;insert()&lt;/code&gt; calls in a process that never crashed. It carries no WAL-replay history for &lt;code&gt;optimize()&lt;/code&gt; to silently exclude. The crash-specific exclusion bug simply doesn't apply to a collection that was never itself a crash victim — moving the data sidesteps the defect rather than working around it in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Temporal Integration &amp;amp; Ledger Catch
&lt;/h2&gt;

&lt;p&gt;Step 2 above — &lt;code&gt;crashed.fetch(all_ids, ...)&lt;/code&gt; — has a dependency that's easy to gloss over: it requires already knowing &lt;code&gt;all_ids&lt;/code&gt;. In the test, that was possible only because the test harness used a predictable, sequential ID scheme (&lt;code&gt;"0"&lt;/code&gt; through &lt;code&gt;"550"&lt;/code&gt;). ZVec's API was checked for a general-purpose "list every document ID in this collection" or full-collection-scan primitive, and none was found. If a production collection's ID space isn't known in advance — UUIDs generated per-document, IDs assigned by an upstream system, IDs accumulated across many separate insert calls over time — step 2 has no way to execute. &lt;code&gt;fetch()&lt;/code&gt; requires you to already know what you're fetching.&lt;/p&gt;

&lt;p&gt;This is not a defect to route around cleverly inside ZVec; it's a boundary that another system in the stack needs to own. And for teams already running ZVec inside Temporal Activities, that system is usually already present: &lt;strong&gt;Temporal's own workflow state.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A Workflow that owns document ingestion into a ZVec collection is a natural place to also maintain the ID ledger the recovery path needs — not as an afterthought, but as a first-class piece of the workflow's durable state, updated in the same Activity call that performs the &lt;code&gt;insert()&lt;/code&gt;. Concretely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every &lt;code&gt;insert()&lt;/code&gt; Activity records the document IDs it just wrote into Workflow state (or an external store the Workflow tracks — a small side table, a durable queue offset, whatever fits the existing stack).&lt;/li&gt;
&lt;li&gt;A recovery Workflow, triggered on detecting the "crash residue" warning at &lt;code&gt;open()&lt;/code&gt; time, reads the ID ledger from Temporal state rather than guessing at ZVec's contents.&lt;/li&gt;
&lt;li&gt;The recovery Workflow then runs Steps 2–5 above: &lt;code&gt;fetch()&lt;/code&gt; using the ledger's ID list, reinsert into a fresh collection, &lt;code&gt;optimize()&lt;/code&gt;, and — as the final step — atomically repoint whatever downstream code holds the collection path so it now reads from the recovered collection instead of the crashed one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these steps require ZVec to expose an enumeration API it doesn't have. The ledger question is answered by the layer that was already responsible for orchestrating the writes in the first place, which is precisely the kind of durable bookkeeping problem Temporal workflow state exists to solve. ZVec's gap in ID enumeration isn't a reason to avoid it — it's a reason not to treat it as a self-sufficient system of record. Pair it with something that already remembers what it wrote, and the gap closes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Taken as a whole, this investigation supports a specific, narrow, and actionable set of conclusions rather than a verdict on ZVec as a whole:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raw document durability across a hard crash is solid.&lt;/strong&gt; Every kill-9 test in this investigation recovered &lt;code&gt;doc_count&lt;/code&gt; exactly, with zero loss and zero duplication, via automatic WAL replay on &lt;code&gt;open()&lt;/code&gt; — no manual recovery call required for the document data itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;index_completeness: 0.0&lt;/code&gt; is a normal, harmless default&lt;/strong&gt;, not a crash indicator. It appears identically on collections that have never crashed and collections that just finished recovering from one. &lt;code&gt;query()&lt;/code&gt; works correctly against it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;optimize()&lt;/code&gt; is unsafe specifically — and only — on a collection with crash history.&lt;/strong&gt; The exclusion of WAL-replayed data from the index rebuild is not a general property of unflushed data; a crash-free collection with unflushed documents survives &lt;code&gt;optimize()&lt;/code&gt; intact. The determining variable is whether the collection was ever killed mid-write, not whether it was recently flushed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one procedure measured to avoid the loss entirely is migration, not in-place repair&lt;/strong&gt;: fetch every document from the crashed collection, reinsert it into a freshly created collection via the normal &lt;code&gt;insert()&lt;/code&gt; path, and only call &lt;code&gt;optimize()&lt;/code&gt; on that new, crash-free collection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The operational rule that follows is simple enough to put directly into a runbook: &lt;strong&gt;if a collection has ever shown the crash-residue warning on reopen, do not call &lt;code&gt;optimize()&lt;/code&gt; on it directly.&lt;/strong&gt; Reinsert its contents into a new collection first. And because that migration path depends on knowing the full ID space — something ZVec itself cannot enumerate — any production deployment inside an orchestrator like Temporal should treat the orchestrator's own durable state as the ID ledger of record, not an optional convenience. ZVec earns its keep as a fast, in-process vector store; it does not, on its own, earn the role of being the only source of truth for what it contains.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>python</category>
      <category>temporal</category>
    </item>
    <item>
      <title>Building Fault-Tolerant AI Agent Workflows with Temporal and CrewAI</title>
      <dc:creator>t-obara</dc:creator>
      <pubDate>Wed, 08 Jul 2026 01:13:51 +0000</pubDate>
      <link>https://dev.to/obataka/building-fault-tolerant-ai-agent-workflows-with-temporal-and-crewai-3hmk</link>
      <guid>https://dev.to/obataka/building-fault-tolerant-ai-agent-workflows-with-temporal-and-crewai-3hmk</guid>
      <description>&lt;p&gt;&lt;em&gt;A reference pattern for running multi-agent LLM systems under strict human governance in production.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;Reference Architecture &amp;amp; Demo Video:&lt;/strong&gt; [&lt;a href="https://project-sy5bk-qyr66bsfr-obataka123.vercel.app/lp.html" rel="noopener noreferrer"&gt;https://project-sy5bk-qyr66bsfr-obataka123.vercel.app/lp.html&lt;/a&gt;]
&lt;/h2&gt;

&lt;h2&gt;
  
  
  1. The Challenge of Enterprise AI Agents
&lt;/h2&gt;

&lt;p&gt;Most "AI agent" demos share three properties that make them unfit for enterprise use: they hold state in process memory, they treat human approval as an optional UI affordance rather than a hard gate, and they have no answer for what happens when a call to the LLM provider times out at 2am.&lt;/p&gt;

&lt;p&gt;None of these are edge cases. In a real deployment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State outlives the process.&lt;/strong&gt; A document-generation or approval workflow can legitimately wait &lt;em&gt;days&lt;/em&gt; for a human reviewer. A Python script with an &lt;code&gt;input()&lt;/code&gt; call, or a LangGraph graph checkpointed to a database you manage yourself, does not survive a deploy, a container restart, or a crash during that wait — unless you build and maintain your own durability layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM calls fail in mundane, frequent ways.&lt;/strong&gt; Rate limits, transient 5xx errors, network blips. These are not exceptional — they are the normal operating condition of any system that calls an external LLM API at volume. Retry logic bolted onto application code tends to be inconsistent: some call sites retry, some don't, backoff parameters drift between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human-in-the-loop is not a webhook.&lt;/strong&gt; If "wait for approval" is implemented as "poll a database row," you've built a distributed system without admitting it, and you inherit all its failure modes (missed writes, race conditions between the poller and the approver) without any of the tooling to reason about them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design question this article answers is narrow: &lt;strong&gt;how do you get a state machine that survives process death, retries failures according to an explicit policy, and blocks on human judgment for an unbounded period — without writing that infrastructure yourself?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reference implementation here is an SOP (Standard Operating Procedure) auto-improvement pipeline: an LLM drafts a document in three phases, a human approves or sends back feedback at each phase, an autonomous validation/fix loop (CrewAI, two agents) cleans up the result, and a final human gate approves a GitHub PR. The domain is incidental. The pattern — orchestration engine as the source of truth for state, agent framework as a stateless reasoning unit invoked from inside an Activity — is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Architecture Overview
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Temporal, specifically
&lt;/h3&gt;

&lt;p&gt;The alternative most teams reach for first is a graph-based agent framework (LangGraph and similar) running as a long-lived process, or a hand-rolled script with a job queue. Both push the durability problem back onto the application:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Plain script / job queue&lt;/th&gt;
&lt;th&gt;LangGraph (self-managed checkpointing)&lt;/th&gt;
&lt;th&gt;Temporal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Survives process crash mid-wait&lt;/td&gt;
&lt;td&gt;No — in-memory state is gone&lt;/td&gt;
&lt;td&gt;Depends on your checkpoint store's consistency guarantees&lt;/td&gt;
&lt;td&gt;Yes — event history is the state; a new worker replays it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-day human approval wait&lt;/td&gt;
&lt;td&gt;Requires a polling job + your own timeout handling&lt;/td&gt;
&lt;td&gt;Requires your own checkpoint schema for "waiting" state&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;workflow.wait_condition&lt;/code&gt; on a &lt;code&gt;@workflow.signal&lt;/code&gt;, no polling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry policy for LLM/API calls&lt;/td&gt;
&lt;td&gt;Ad hoc, per call site&lt;/td&gt;
&lt;td&gt;Ad hoc, per node&lt;/td&gt;
&lt;td&gt;Declared once (&lt;code&gt;RetryPolicy&lt;/code&gt;), enforced by the runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploying new code while workflows are in flight&lt;/td&gt;
&lt;td&gt;Breaks in-flight state silently&lt;/td&gt;
&lt;td&gt;Breaks in-flight state silently&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;workflow.patched()&lt;/code&gt; — explicit, versioned branching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit trail of what happened and when&lt;/td&gt;
&lt;td&gt;You build it&lt;/td&gt;
&lt;td&gt;You build it&lt;/td&gt;
&lt;td&gt;Event history is authoritative by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two rows are the ones that actually matter at enterprise scale, and they're the ones demos never show.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Determinism and replay.&lt;/strong&gt; A Temporal Workflow function is not "running" in the conventional sense — it is &lt;em&gt;replayed&lt;/em&gt; from its event history every time the worker needs to reconstruct its state (after a crash, a deploy, a worker restart). This means the workflow function itself must be deterministic: no wall-clock time, no random numbers, no direct I/O. All non-deterministic work — LLM calls, GitHub API calls, validation — is pushed into Activities, which &lt;em&gt;are&lt;/em&gt; allowed side effects and are retried independently by the runtime. &lt;code&gt;sop_workflow.py&lt;/code&gt; enforces this at the import level:&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;with&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unsafe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imports_passed_through&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;activities.sop_activity&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;generate_sop_phase_activity&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;activities.fix_sop_activity&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fix_sop_with_crew_activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;activities.github_activity&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GitHubActivity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CrewAI itself imports &lt;code&gt;os&lt;/code&gt;, makes network calls, and is not sandbox-safe — it cannot be imported inside the workflow's deterministic sandbox at all. It only ever runs inside an Activity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Versioning in-flight workflows.&lt;/strong&gt; This is the detail most articles skip. A workflow that's been running for two days is mid-way through its event history when you ship new code. If you change the code path a running instance already took, replay diverges from history and the workflow breaks. &lt;code&gt;sop_workflow.py&lt;/code&gt; handles this with an explicit branch:&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;if&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;split-writer-reviewer&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_call_fix_decomposed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sop_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;human_feedback&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;await&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fix_sop_with_crew_activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sop_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;human_feedback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fix_attempt&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;start_to_close_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;retry_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LLM_RETRY_POLICY&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 a real production concern: the fix-loop was refactored from one monolithic CrewAI &lt;code&gt;kickoff()&lt;/code&gt; call into two separate Activities (Writer, then Reviewer). Workflow instances already in flight when that code shipped keep using the old single-Activity path (&lt;code&gt;fix_sop_with_crew_activity&lt;/code&gt;); new instances take the split path. Without &lt;code&gt;patched()&lt;/code&gt;, this refactor would non-deterministically break any workflow that was mid-execution at deploy time. There is no equivalent primitive in a hand-rolled state machine — you'd need to build and version your own replay-compatibility layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mapping Temporal to CrewAI
&lt;/h3&gt;

&lt;p&gt;CrewAI's contribution here is narrow and deliberate: it is a reasoning unit, not an orchestrator. It never owns state, never decides when to wait for a human, and never retries itself. Two agents — Writer and Reviewer — are defined per the standard CrewAI &lt;code&gt;Agent&lt;/code&gt;/&lt;code&gt;Task&lt;/code&gt; API:&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;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&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;SOP 修正担当&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;goal&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;SOP のバリデーション失敗項目を全て解消し、&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;最小限の変更で明確・再現性の高い改善版を Markdown 形式で出力する。&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;backstory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5 年以上のテクニカルライター経験を持つ専門家。...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;verbose&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;allow_delegation&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Critically, in the current code path this pair is &lt;strong&gt;not&lt;/strong&gt; invoked as a single CrewAI &lt;code&gt;Crew.kickoff()&lt;/code&gt; from inside one Activity. It's decomposed into two independent Activities, each with its own retry policy:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_call_fix_decomposed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sop_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;human_feedback&lt;/span&gt;&lt;span class="o"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_active_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Writer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;writer_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;writer_task_activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sop_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;human_feedback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fix_attempt&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;start_to_close_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;retry_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LLM_RETRY_POLICY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_active_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reviewer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;reviewer_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_activity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;reviewer_task_activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;writer_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;start_to_close_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;retry_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;LLM_RETRY_POLICY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consequence: if the Reviewer's LLM call fails transiently, Temporal retries &lt;em&gt;only&lt;/em&gt; the Reviewer Activity. The Writer's already-committed output is not re-run, re-billed, or re-generated (which would risk non-identical output on a second pass). This is the practical argument for keeping the agent framework's unit of work small and pushing sequencing up into the orchestrator — multi-agent frameworks that bundle several agents into one opaque &lt;code&gt;kickoff()&lt;/code&gt; call give you all-or-nothing retry semantics, which is the wrong grain for cost- and latency-sensitive LLM pipelines.&lt;/p&gt;

&lt;p&gt;Retry policy itself is centralized and explicit, not scattered:&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;LLM_RETRY_POLICY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RetryPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;maximum_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;initial_interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&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;backoff_coefficient&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;maximum_interval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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;Every LLM- or API-backed Activity in the workflow — generation, validation, fix, GitHub PR creation — takes this same policy. There is one place to change backoff behavior, and it is enforced by the Temporal server, not by application code that might forget to call it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Implementing Strict Human-in-the-Loop with Temporal Signals
&lt;/h2&gt;

&lt;p&gt;The product enforces four hard human judgment gates. None are advisory; the workflow physically cannot proceed past any of them without an external Signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gate 1–3: per-phase approval (outline → draft → review).&lt;/strong&gt; Each phase runs the same pattern: generate, then block on a Signal.&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;@workflow.signal&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;approve_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feedback&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="bp"&gt;None&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="s"&gt; (空文字): 現フェーズを承認して次フェーズへ進む。
    非空文字:   フィードバックとして同フェーズを再生成する。
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_step_feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_signal_received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PHASES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# ["outline", "draft", "review"]
&lt;/span&gt;    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_current_phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_attempt_in_phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;last_feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;while&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_signal_received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;   &lt;span class="c1"&gt;# reset BEFORE the Activity runs
&lt;/span&gt;        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_call_llm&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_current_output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awaiting_approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_condition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_signal_received&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_step_feedback&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_step_feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_signal_received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_approved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_current_output&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;                        &lt;span class="c1"&gt;# move to next phase
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;last_feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_attempt_in_phase&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;# regenerate same phase
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail matters more than it looks: &lt;code&gt;self._signal_received = False&lt;/code&gt; is reset &lt;em&gt;before&lt;/em&gt; &lt;code&gt;await self._call_llm(...)&lt;/code&gt;, not after. Signals delivered while the Activity is still executing (e.g., an eager reviewer clicking "approve" mid-generation) are not lost — &lt;code&gt;wait_condition&lt;/code&gt; picks up the flag the instant control returns to the workflow. A polling-based approval mechanism would have a real race window here; Temporal's Signal delivery guarantees there isn't one.&lt;/p&gt;

&lt;p&gt;An empty-string feedback approves and advances; any non-empty string is treated as a rejection with revision instructions, and the &lt;em&gt;same phase&lt;/em&gt; regenerates with that feedback injected into the next LLM request (&lt;code&gt;SOPRequest.feedback&lt;/code&gt;). This gives reviewers a single Signal type for two distinct actions, and gives the workflow author-level (not binary) revision control instead of just approve/reject.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gate 4: PR approval, with a reject path.&lt;/strong&gt; The final gate — before creating a GitHub PR — supports both approval and a full round-trip back into the autonomous fix loop:&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;@workflow.signal&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;approve_pr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pr_approved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="nd"&gt;@workflow.signal&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reject_with_feedback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input_data&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="o"&gt;-&amp;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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_human_feedback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comment&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_data&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;else&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;input_data&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;github_params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;require_approval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awaiting_pr_approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pr_approved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_condition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pr_approved&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_human_feedback&lt;/span&gt;&lt;span class="p"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_human_feedback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;   &lt;span class="c1"&gt;# loop back to Phase 4 (autonomous_fix) with the feedback
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;creating_pr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;pr_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_call_github_pr&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wait_condition&lt;/code&gt; here blocks on a disjunction of two Signals, which lets a single wait point resolve two different human outcomes without a secondary polling loop. A rejection doesn't just pause — it re-enters the fix loop with the human's comment injected as a first-class input alongside the validator's own failure list:&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;if&lt;/span&gt; &lt;span class="n"&gt;human_feedback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fix_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_call_fix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final_sop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="n"&gt;human_feedback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why not just a database flag checked by a cron job?&lt;/strong&gt; Because that reintroduces every problem Temporal exists to solve: you'd need your own polling interval (latency vs. cost tradeoff), your own guarantee that a write isn't missed between poll cycles, and your own crash recovery for the poller itself. &lt;code&gt;wait_condition&lt;/code&gt; on a Signal is push-based, has no polling interval, and is backed by the same event history that makes the rest of the workflow durable — the wait &lt;em&gt;is&lt;/em&gt; the crash-recovery mechanism, not a separate one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability without extra infrastructure.&lt;/strong&gt; Every phase and fix attempt — approved or not — is appended to &lt;code&gt;self._history&lt;/code&gt;, exposed via a &lt;code&gt;@workflow.query&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="nd"&gt;@workflow.query&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;全フェーズ・全試行の比較ログ。人間介入の Evidence として使用する。&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_history&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 audit trail: every LLM output the workflow ever produced, whether a human approved or rejected it, and what feedback drove each regeneration — queryable at any point, including on a workflow that's still running, with no separate logging pipeline to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Conclusion &amp;amp; Reference Implementation Pattern
&lt;/h2&gt;

&lt;p&gt;Stripped of the SOP-specific domain logic, the pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Workflow owns state and sequencing; Activities own side effects.&lt;/strong&gt; LLM calls, validation, and GitHub operations are Activities with an explicit, shared &lt;code&gt;RetryPolicy&lt;/code&gt;. The workflow function itself does nothing non-deterministic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human approval is a blocking Signal wait, not a poll.&lt;/strong&gt; &lt;code&gt;workflow.wait_condition(lambda: flag)&lt;/code&gt; combined with &lt;code&gt;@workflow.signal&lt;/code&gt; handlers gives push-based, race-free blocking that survives process restarts for however long a human takes — minutes or days, no difference to the implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-agent reasoning is decomposed to Activity granularity.&lt;/strong&gt; CrewAI agents run as separate Activities (Writer, then Reviewer) rather than one opaque multi-agent &lt;code&gt;kickoff()&lt;/code&gt;, so retries are scoped to the agent that actually failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code changes to long-running workflows are versioned explicitly&lt;/strong&gt;, via &lt;code&gt;workflow.patched()&lt;/code&gt;, rather than silently breaking workflow instances that are mid-flight at deploy time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is specific to SOP generation. The same shape applies to any pipeline where an LLM produces something a human must sign off on before it has real-world effect — code review automation, contract drafting, financial report generation — where "the process might be paused for two days waiting on a person" is not a corner case to be handled, but the normal operating condition the architecture has to be built around from the start.&lt;/p&gt;




&lt;h3&gt;
  
  
  About the Reference Implementation
&lt;/h3&gt;

&lt;p&gt;The code patterns discussed in this article were extracted from a production-ready SOP (Standard Operating Procedure) improvement platform built to demonstrate high-durability AI agents.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Architecture &amp;amp; Video Demo:&lt;/strong&gt; [&lt;a href="https://project-sy5bk-qyr66bsfr-obataka123.vercel.app/lp.html" rel="noopener noreferrer"&gt;https://project-sy5bk-qyr66bsfr-obataka123.vercel.app/lp.html&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect on LinkedIn:&lt;/strong&gt; [&lt;a href="https://www.linkedin.com/in/takashi-obara-1a5305150/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/takashi-obara-1a5305150/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact / Feedback:&lt;/strong&gt; If you are an enterprise engineering leader tackling similar state-management or human-governance challenges with LLMs, I would welcome a high-context technical discussion. You can reach out to me via LinkedIn or through the contact form on the architecture page above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Note: I am a hands-on Japanese engineer. While the technical architecture and code in this article are 100% mine, I use AI to assist with English translation for global communication.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ Reference Implementation &amp;amp; Code
&lt;/h2&gt;

&lt;p&gt;The complete, production-ready codebase with Docker Compose setup is fully documented and open-sourced here:&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://github.com/obataka/temporal-demo" rel="noopener noreferrer"&gt;GitHub: obataka/temporal-demo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>temporal</category>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
