Durable Execution untuk AI Agent yang Berjalan Berjam-jam

Durable execution menulis setiap step yang selesai ke dalam sebuah journal, sehingga restart cukup memutar ulang journal itu dan melewati pekerjaan yang sudah beres. Unit retry sebuah job queue adalah seluruh job, jadi kegagalan di step kesembilan membuat step satu sampai delapan dijalankan ulang dari nol. Perbedaannya ada pada granularitas pemulihan, bukan pada keandalan penyimpanannya.
Setiap LLM call dan tool call berbayar di dalam sebuah agent run ditagih saat terjadi, bukan saat run-nya selesai. Kalau run crash di step kesembilan lalu retry dimulai dari step pertama, delapan call yang sudah berhasil itu Anda bayar dua kali. Durable execution mencegahnya dengan mengembalikan output yang tercatat dari setiap step yang selesai, tanpa memanggil model lagi.
Tidak. Satu agent call yang selesai dalam beberapa detik tidak punya apa pun yang layak dilanjutkan, dan satu idempotency key plus tabel steps di Postgres sudah cukup untuk pipeline pendek. Pertimbangkan workflow platform ketika run cukup panjang untuk terinterupsi, cukup mahal sehingga pengulangannya terasa, atau berhenti menunggu sesuatu di luar proses Anda. Temporal adalah service yang harus dioperasikan, dan itu biaya nyata bagi tim kecil.
Kode workflow wajib deterministik karena runtime mengeksekusinya berulang kali saat replay, dan dokumentasi Temporal mensyaratkan kode itu melakukan API call yang sama dalam urutan yang sama untuk input yang sama. Model bisa mengembalikan jawaban berbeda untuk prompt yang sama, jadi ia tidak akan pernah memenuhi aturan itu. Menaruh call tersebut di activity atau di step ctx.run membuat hasilnya dicatat sekali lalu diputar ulang setelahnya.
Durable runtime menyimpan penantian itu sendiri secara persisten. Durable sleep di Restate adalah entry journal milik server, sehingga handler yang sudah tidur delapan dari dua belas jam lalu crash hanya tidur empat jam sisanya. Untuk keputusan manusia Anda memakai awakeable atau named signal: runtime men-suspend invocation, dan sistem eksternal menyelesaikan token itu kapan pun orangnya benar-benar bertindak.

Ringkasan Utama
Durable execution mencatat setiap step yang selesai dari sebuah AI agent berdurasi panjang ke dalam journal, sehingga crash dilanjutkan dari step yang gagal, bukan dari awal. Job queue mengulang seluruh job dan membayar ulang setiap LLM call yang sudah terjadi. Temporal, Restate, DBOS dan Inngest menjual jaminan yang sama itu.
Escalation scheduler di hierarchical-approval ada karena sebuah purchase order bisa tertahan di level dua selama tiga hari saat finance director sedang cuti. Saya sengaja membuat engine-nya menerima Clock yang bisa di-inject plus ManualClock untuk testing, supaya proses berskala hari bisa diverifikasi dalam hitungan milidetik. Bentuk yang sama muncul begitu sebuah agent berjalan cukup lama sampai gagal di tengah jalan. Agent yang mati di tool call kesembilan lalu dijalankan ulang dari yang pertama oleh job queue biasa bukan cuma kehilangan waktu: ia membayar ulang delapan model call yang sudah berhasil. Itulah arti sebuah retry ketika pekerjaannya ditagih per token.
Kegagalan kedua itu bukan bug pada queue. Itu ketidakcocokan granularitas: unit retry sebuah queue adalah job, dan job-nya adalah seluruh agent run. Durable execution menurunkan unit retry ke level step. Tulisan ini adalah hasil saya membandingkan empat runtime yang melakukannya, apa yang dituntut model replay dari kode Anda, dan kapan semua ini justru tidak sepadan dengan biaya operasionalnya.
Kesimpulannya dulu: job queue dan durable runtime sama-sama selamat dari crash, tetapi keduanya melanjutkan di titik yang berbeda. Kontrak BullMQ adalah job yang gagal dijalankan lagi, seluruh handler, dari baris pertamanya. Kontrak Restate adalah handler dijalankan lagi tetapi setiap step yang sudah selesai mengembalikan hasil yang tercatat, bukan dieksekusi ulang. Dokumentasi Restate menyebutnya lugas: Restate memutar ulang journal, melewati step yang sudah selesai, dan melanjutkan tepat dari titik terakhir. Isi journal itulah kuncinya.
Karena itu perbedaannya soal uang, bukan soal selera. Untuk job CRUD, mengulang dari awal biayanya beberapa ratus milidetik. Untuk agent yang sudah melakukan delapan model call, mengunduh tiga PDF dan menulis satu row, mengulang dari awal berarti membayar delapan call itu lagi, ditambah row duplikat untuk setiap step yang tidak idempotent.

Beginilah bentuk kesalahannya. Versi naif adalah satu queue job yang membungkus seluruh loop, dan itu versi yang hampir selalu ditulis orang pertama kali, karena versi itu bekerja mulus di happy path dan terbaca wajar saat code review.
// Wrong: one BullMQ job for the entire agent run.
// A throw on the ninth tool call re-queues the job, and attempt 2 restarts
// at line 1 — the eight model calls before the crash are billed again.
worker.process("tender-review", async (job) => {
const plan = await llm.plan(job.data.brief); // paid
const notes = [];
for (const url of plan.sources) {
notes.push(await summarisePage(url)); // paid, once per source
}
return llm.compose(notes); // paid
});
// Right: every paid call is its own journalled step.
// Attempt 2 still re-enters the handler from the top, but each completed
// ctx.run returns its recorded result instead of calling the model again.
export const tenderReview = restate.workflow({
name: "TenderReview",
handlers: {
run: async (ctx: restate.WorkflowContext, brief: string) => {
const plan = await ctx.run("plan", () => llm.plan(brief));
const notes = [];
for (const url of plan.sources) {
// One journal entry per source, so a resume lands on the source
// that actually failed rather than on the first one.
notes.push(await ctx.run("page:" + url, () => summarisePage(url)));
}
return ctx.run("compose", () => llm.compose(notes));
},
},
});Versi durable bukan kode yang lebih panjang, melainkan kode dengan batas yang berbeda. Setiap paid call duduk di dalam ctx.run miliknya sendiri. Pada percobaan kedua handler tetap dijalankan dari atas, bagian itu tidak berubah, tetapi step plan dan tujuh ringkasan halaman dikembalikan dari journal tanpa menyentuh model, dan eksekusi benar-benar berlanjut di halaman kedelapan. Inngest menjelaskan mekanisme yang sama dengan kata berbeda: function dieksekusi ulang bersama state dari eksekusi sebelumnya, dan untuk step yang sudah berhasil SDK menyuntikkan hasilnya ke return value alih-alih menjalankan kodenya.
Replay hanya bekerja jika kode di sekitar step mengambil keputusan yang sama dalam urutan yang sama setiap kali. Temporal menyatakan batasannya secara langsung: setiap kali kode workflow Anda dieksekusi, ia harus melakukan API call yang sama dalam urutan yang sama untuk input yang sama, dan operasi non-deterministik seperti API call, LLM invocation dan database query harus ditempatkan di Activity. Orang sering menolak aturan ini karena terlihat sewenang-wenang, sampai asimetrinya masuk akal: jalur orchestration dieksekusi berkali-kali, sedangkan step-nya dieksekusi sekali.
// Wrong: the orchestration path branches on values that change on replay.
if (Date.now() - startedAt > 3_600_000) return giveUp(); // replay disagrees
const shard = Math.floor(Math.random() * 4); // replay disagrees
const fx = await fetch(RATES_URL).then((r) => r.json()); // replay re-calls
// Right: replay-stable equivalents, or push the call into a step.
const now = await ctx.date.now(); // millis, consistent across retries
const shard = Math.floor(ctx.rand.random() * 4); // seeded by the invocation id
const fx = await ctx.run("fx-rate", () =>
fetch(RATES_URL).then((r) => r.json()),
);
await ctx.sleep({ seconds: 3600 }); // a durable timer, not setTimeout:
// the process may not exist in an hour
// An LLM call can return a different answer to the same prompt, so it can
// never sit in the deterministic path. It is a step by construction.
const answer = await ctx.run("classify", () => llm.classify(doc));Perhatikan apa artinya itu bagi sebuah model call. LLM bisa mengembalikan jawaban berbeda untuk prompt yang sama, jadi ia tidak pernah boleh berada di jalur deterministik; secara konstruksi ia adalah step. Ini justru menguntungkan, bukan membatasi. Hal yang paling ingin Anda hindari untuk dijalankan ulang adalah persis hal yang dipaksa runtime untuk diisolasi, sehingga agent durable yang ditulis benar mendapat penghematan biaya sebagai efek samping dari mematuhi aturan determinisme.

Pakai random bawaan runtime untuk idempotency key, bukan fungsi UUID milik platform. ctx.rand.uuidv4 di Restate di-seed oleh invocation ID, sehingga retry mengirimkan key yang sama seperti yang dilihat vendor pertama kali. Kalau key itu dibuat dengan crypto.randomUUID, setiap replay akan terlihat sebagai request baru bagi payment atau model API yang justru ingin Anda deduplikasi.
Perbedaan menarik antar produk ini bukan pada API-nya. Semuanya bertemu pada ide yang sama, yaitu membungkus side effect. Perbedaannya ada pada apa yang harus Anda jalankan dan di mana journal-nya tinggal, dan itulah yang menentukan apakah adopsi memakan satu sore atau satu kuartal.
| Runtime | Di mana execution state disimpan | Apa yang harus Anda deploy | Bagaimana sebuah run dilanjutkan | SDK |
|---|---|---|---|---|
| Temporal | Event History, disimpan oleh Temporal Service | Satu Temporal Service plus proses Worker milik Anda sendiri | Worker memutar ulang kode dan mencocokkan tiap Command dengan Event yang sudah ada di history | Go, Java, TypeScript, Python, .NET, PHP, Ruby |
| Restate | Journal plus key-value store yang tertanam di dalam Restate Server | Restate Server; handler Anda tetap berupa HTTP service biasa | Restate memanggil ulang handler dan memutar journal, melewati step yang sudah selesai | TypeScript, Java, Kotlin, Go, Python, Rust |
| DBOS | Checkpoint di dalam system database Postgres yang sudah Anda miliki | Tidak ada selain Postgres; library-nya berjalan di dalam proses Anda | Aplikasi menemukan workflow yang tertinggal pending lalu menjalankannya ulang dengan output step yang sudah di-checkpoint | Python, TypeScript, Go, Java |
| Inngest | State step yang dimemoisasi di function state store terkelola milik Inngest | Satu HTTP endpoint yang disajikan aplikasi Anda; Inngest yang mengorkestrasi dari sisinya | Function Anda dipanggil ulang bersama state sebelumnya dan SDK menyuntikkan hasil step yang dimemoisasi | TypeScript, Python, Go |
DBOS adalah pengecualian yang layak diperhatikan kalau Anda sudah menjalankan Postgres. Halaman architecture-nya menyatakan tegas bahwa tidak ada orchestration server terpisah dan tidak ada infrastruktur lain yang dibutuhkan selain Postgres, karena library-nya menulis checkpoint workflow dan step ke system database di cluster Anda sendiri. Temporal adalah pertukaran sebaliknya: ada service yang harus dioperasikan, ditukar dengan cerita paling matang soal visibility, versioning dan worker multi-bahasa. Restate berada di tengah, dengan server sebagai satu-satunya proses baru dan handler yang tetap HTTP service biasa.
Di sinilah pekerjaan ERP dan pekerjaan agent ternyata satu masalah yang sama. Di hierarchical-approval sebuah instance memang wajar tertahan pending sampai ada orang yang bertindak, slaDeadlineDays menentukan kapan escalation scheduler harus peduli, dan engine-nya menerima Clock agar hari-hari itu bisa disimulasikan. Sebuah test memajukan ManualClock tiga hari, men-tick scheduler, dan event SLA breach menyala tanpa satu detik pun waktu nyata berlalu. Saya membangun itu karena tidak ada cara lain menguji deadline tiga hari di dalam CI.
// hierarchical-approval: the engine takes a Clock, so "three days" is testable.
import { ApprovalTestKit } from "hierarchical-approval/testing";
const { engine, clock } = ApprovalTestKit.create(); // MemoryAdapter + ManualClock
await engine.defineTemplate({
name: "purchase-order",
documentType: "purchase_order",
levels: [{ level: 1, name: "Finance", approvers: [finance], mode: "any" }],
slaDeadlineDays: 2,
});
clock.advanceDays(3); // no real timers, no three-day test run
await engine["escalation"].tick(); // 'approval:sla_breached' fires here
// A durable runtime hands you the same two primitives, first-class:
const { id, promise } = ctx.awakeable(); // one-shot token, like an SLA row
await ctx.run(() => emailApprover(instanceId, id));
const decision = await promise; // suspends the invocation; survives a restart
// Or a named signal, which can be resolved more than once — the same channel
// works for "approved" and for steering an agent mid-run.
const steer = await ctx.signal("steer");Durable runtime memberi Anda dua primitif itu sebagai konstruksi tingkat bahasa, bukan sesuatu yang harus Anda bangun sendiri. Durable sleep adalah entry journal yang dimiliki server, jadi ia hidup lebih lama daripada proses yang memulainya. Awakeable menyerahkan token sekali pakai kepada sistem eksternal untuk diselesaikan, dan tabel di dokumentasi Restate menyebut agent steering dan human approval sebagai kegunaan signal. Agent yang berhenti menunggu persetujuan dan approval yang berhenti menunggu direktur adalah bentuk yang sama; saya sudah menulis versi sempit dan spesifik dari sesuatu yang ternyata ada bentuk umumnya.
hierarchical-approval adalah npm package saya sendiri, sebuah hierarchical approval engine TypeScript-first untuk ERP developer, berlisensi MIT dan saat ini di versi 4.0.0. Package ini tidak dibangun di atas durable execution runtime: ia menyimpan state lewat storage adapter-nya sendiri dan menjalankan escalation scheduler-nya sendiri. Itu perbandingan yang jujur, kebutuhan yang sama diselesaikan secara sempit untuk satu domain, bukan secara umum. hierarchical-approval
Tiga hal ini mengejutkan saya, dan ketiganya didokumentasikan sendiri oleh vendornya, bukan ditemukan dalam incident review siapa pun. Tidak satu pun jadi alasan menghindari durable execution, tetapi masing-masing adalah alasan membaca dokumentasi runtime sebelum agent masuk production, bukan sesudahnya.
Journal adalah artefak nyata yang punya ukuran, skema dan permukaan kompatibilitas, dan kini ia duduk di antara pipeline deploy Anda dan agent yang sedang berjalan. Perlakukan seperti migrasi database, bukan seperti file log.
Kegagalan yang berbahaya bukan crash, melainkan resume yang salah. Rilis perubahan pada jalur orchestration saat masih ada run yang berjalan, dan sebuah replay bisa sampai di titik keputusan yang tidak ada lagi di kode, sehingga run itu entah menabrak pemeriksaan non-determinisme atau diam-diam mengambil cabang yang tidak diinginkan siapa pun. Lakukan versioning workflow secara sadar dan biarkan versi lama tetap melayani sampai run-nya habis; kode orchestration bukan kode aplikasi yang bisa Anda hotfix.
Bagian yang tidak populer, saya sampaikan terus terang: sebagian besar agent call tidak perlu mendekati workflow engine sama sekali. Satu tool call yang selesai dalam lima detik tidak punya apa pun untuk dilanjutkan. Kalau gagal, jalankan lagi, dan yang hilang cuma lima detik plus satu model call. Durable execution baru sepadan kalau sebuah run cukup panjang untuk terinterupsi, cukup mahal sehingga pengulangannya terasa, atau berhenti menunggu sesuatu di luar proses Anda. Ini tangga yang sekarang saya naiki, dan saya berhenti di anak tangga pertama yang sudah cukup.
Untuk tim berdua, anak tangga keempat adalah tagihan nyata yang dibayar dengan perhatian: satu service untuk dijalankan, satu disiplin versioning untuk dijaga, dan satu kelas kegagalan baru untuk dipelajari cara membacanya. Saya tidak akan membayarnya demi satu agent. Saya akan membayarnya demi satu armada, dan sinyal jujur bahwa Anda punya armada adalah ketika dua tim berbeda sudah sama-sama menulis tabel steps mereka sendiri.
Aturan yang saya bawa dari sini soal granularitas, bukan soal produk. Tanyakan apa unit retry Anda. Kalau jawabannya seluruh job, maka setiap kegagalan menagih Anda lagi untuk pekerjaan yang sudah berhasil, dan pada agent pekerjaan itu dihitung per token. Jadikan step sebagai unitnya, lewat journal, tabel steps, atau apa pun yang sepadan dengan ukuran masalahnya, dan crash berhenti menjadi tagihan.
Sumber dan bacaan lanjutan