@coder/ai-sdk-agent 0.11.7 → 0.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,890 @@
1
+ # Durable workflows: persist, resume, recover
2
+
3
+ Run **one agent session across process boundaries** — queue jobs,
4
+ durable‑workflow steps (Vercel Workflow, step functions, Temporal, …), cron
5
+ ticks — and survive the crashes, stream drops, and timeouts in between. Read
6
+ this when a chat must outlive the process that started it. Back to the
7
+ [package README](../README.md); mechanics are in
8
+ [Sessions](../README.md#sessions),
9
+ [Timeouts & cancellation](../README.md#timeouts--cancellation),
10
+ [Handling errors](../README.md#handling-errors), and
11
+ [Observability](../README.md#observability).
12
+
13
+ How it works:
14
+
15
+ - **All chat state lives on the Coder server** — messages, tool activity, the
16
+ run itself. Between steps, carry only **`agent.chatId`** (a string) and,
17
+ optionally, **`agent.lastSeenMessageId`** (the resume cursor; saves one
18
+ serial round‑trip).
19
+ - **Each turn runs inside a durable step.** `CoderAgent` talks REST +
20
+ WebSocket through its own client, so it can't ride a `fetch`‑shim durability
21
+ layer.
22
+
23
+ | Sections | Covers |
24
+ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
25
+ | [Shape each step](#shape-each-step-one-turn-then-checkpoint) → [Archive](#archive-at-the-end--and-only-at-the-end) | The normal path. |
26
+ | [Let the SDK absorb drops](#let-the-sdk-absorb-drops--and-handle-what-it-rethrows) | Errors that reach your step. |
27
+ | [Recover after a crash](#recover-after-a-crash), [Make client tools crash-safe](#make-client-tools-crash-safe), [Recover the result](#recover-the-result-before-resubmitting) | Hard crashes. The three snippets share variables. |
28
+
29
+ ## Shape each step: one turn, then checkpoint
30
+
31
+ ```ts
32
+ import { CoderAgent, type CoderTransportEvent } from "@coder/ai-sdk-agent";
33
+
34
+ interface WorkflowCheckpoint {
35
+ chatId: string;
36
+ /** The resume cursor — pairs with chatId, never persisted without it. */
37
+ lastSeenMessageId?: number;
38
+ }
39
+
40
+ // Any durable KV your engine gives you: step state, a job row, a DB table.
41
+ declare const checkpoints: {
42
+ get(workflowId: string): Promise<WorkflowCheckpoint | undefined>;
43
+ set(workflowId: string, checkpoint: WorkflowCheckpoint): Promise<void>;
44
+ };
45
+
46
+ export async function runTurn(workflowId: string, prompt: string): Promise<string> {
47
+ const checkpoint = await checkpoints.get(workflowId); // undefined on the first step → the turn creates the chat
48
+ const agent = new CoderAgent({
49
+ baseUrl: process.env.CODER_URL!,
50
+ token: process.env.CODER_SESSION_TOKEN!, // read per step — never checkpoint or log it
51
+ organizationId: process.env.CODER_ORG_ID!,
52
+ chatId: checkpoint?.chatId,
53
+ lastSeenMessageId: checkpoint?.lastSeenMessageId, // optional: skips the pre-prompt history probe
54
+ requestTimeoutMs: 300_000, // per segment — see "Bound every step"
55
+ onTransportEvent: observe, // see "Watch turn health"
56
+ });
57
+
58
+ try {
59
+ const { text } = await agent.generate({
60
+ prompt,
61
+ abortSignal: AbortSignal.timeout(600_000), // total wall-clock for the step
62
+ });
63
+ return text;
64
+ } finally {
65
+ // Checkpoint even when the turn failed: failed-but-alive chats stay reachable.
66
+ if (agent.chatId) {
67
+ const next: WorkflowCheckpoint = { chatId: agent.chatId };
68
+ // A turn that failed before streaming may have no cursor.
69
+ if (agent.lastSeenMessageId !== undefined) {
70
+ next.lastSeenMessageId = agent.lastSeenMessageId;
71
+ }
72
+ await checkpoints.set(workflowId, next);
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ | Rule | Why |
79
+ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
80
+ | **One turn per step; `generate()`, not `stream()`** | The checkpointed unit is a finished result. A mid‑`stream()` failure surfaces on the stream, outside `generate()`'s error contract, which complicates step retries. |
81
+ | **Persist the id, not the instance** | Never the token — read it from each step's environment. |
82
+ | **Checkpoint in `finally`** | A first step that fails _after_ creating its chat (timeout, stream loss) must still persist the id, or the retry orphans a live chat and its partial effects. Only the [hard‑crash window](#recover-after-a-crash) remains. |
83
+ | **No `await using`** | Disposal archives the chat, and [archiving ends resumability](#archive-at-the-end--and-only-at-the-end). |
84
+
85
+ <details><summary>When the <code>finally</code> checkpoint writes nothing</summary>
86
+
87
+ After an exhausted stream failure on a chat this very call created, the SDK has
88
+ already discarded the dead session: `agent.chatId` is `undefined`, so nothing
89
+ is written. The thrown `CoderStreamError`'s `chatId` still names the stranded
90
+ chat, and `agent.archive()` retires it via `agent.lastKnownChatId`. See
91
+ [`CoderStreamError`](#let-the-sdk-absorb-drops--and-handle-what-it-rethrows)
92
+ for what retrying means then.
93
+
94
+ </details>
95
+
96
+ ## Resume in the next step — any process, any machine
97
+
98
+ ```ts
99
+ // Step 1 — a queue job on machine A:
100
+ await runTurn("wf-1042", "Investigate the failing nightly build and propose a fix.");
101
+
102
+ // Step 2 — hours later, on machine B: same chat, full server-side history.
103
+ await runTurn("wf-1042", "Apply the fix you proposed and summarize what changed.");
104
+
105
+ // Final step — cleanup belongs to the workflow, not to each step (see below).
106
+ await archiveWorkflowChat("wf-1042");
107
+ ```
108
+
109
+ - **Construction does no I/O.** A bad or archived `chatId` fails the _turn_,
110
+ not the constructor. Only `lastSeenMessageId` is validated locally: a cursor
111
+ that isn't a positive integer, or lacks its `chatId`, throws immediately.
112
+ - **A resumed turn streams only its own events** — never earlier turns'
113
+ content or usage. Without a checkpointed cursor (first resume, older
114
+ checkpoints), it seeds one from the chat's newest message: one extra
115
+ single‑message GET, same result.
116
+ - **Re‑supply client `tools` in every step that expects tool calls.** They live
117
+ in your process. The workspace binding is the opposite: fixed at creation,
118
+ unchangeable on resume.
119
+ - **Run steps strictly sequentially per chat.** Sessions are single‑flight
120
+ system‑wide, not just per process.
121
+
122
+ <details><summary>What happens if two jobs post to the same chat</summary>
123
+
124
+ The second job queues behind the live run and burns its own `requestTimeoutMs`
125
+ waiting. It attributes nothing of the live run's output to itself — its window
126
+ opens only when its own queued prompt materializes. If it times out first, it
127
+ withdraws its queued prompt rather than leaving it to run unattended.
128
+
129
+ The live run's turn is guarded symmetrically. The server starts the queued
130
+ prompt's run **without ever emitting a terminal settle for the finished one**,
131
+ so the SDK settles the finishing turn the moment the promoted prompt appears on
132
+ its stream; nothing of the next turn's output or usage leaks into it.
133
+
134
+ That settle reports `finishReason: "stop"` as a documented approximation. The
135
+ wire doesn't distinguish a run that completed into a promotion from one
136
+ interrupted by `busy_behavior: "interrupt"`, so an interrupted turn also
137
+ settles `"stop"` (with whatever partial output it committed) rather than
138
+ surfacing an interruption.
139
+
140
+ </details>
141
+
142
+ ## Bound every step
143
+
144
+ `requestTimeoutMs` arms **one timer per segment**, covering the REST phase
145
+ (chat creation, message/tool‑result submission, uploads), the stream, and
146
+ **any redial backoff inside the segment**.
147
+
148
+ - **Redials never reset the clock.** 12 s spent reconnecting is 12 s less
149
+ budget, so a flaky network can't stretch a step past its bound.
150
+ - **On expiry** the call rejects with `CoderChatError` (`kind: "timeout"`,
151
+ `retryable: true`), and the server run is interrupted best‑effort.
152
+ - **Cap total wall‑clock with `abortSignal: AbortSignal.timeout(…)`.** A turn
153
+ driving client tools runs several segments
154
+ ([Timeouts & cancellation](../README.md#timeouts--cancellation)).
155
+ - **A turn that times out (or crashes) before its chat id arrived** leaves
156
+ nothing to _resume_ — the checkpoint stays empty and the retried step starts
157
+ fresh — but possibly an unacknowledged chat to sweep
158
+ ([Recover after a crash](#recover-after-a-crash)).
159
+
160
+ ## Watch turn health from inside the step
161
+
162
+ Emit [transport events](../README.md#observability) as step metrics/traces;
163
+ they classify how each segment ended without parsing errors or stream frames:
164
+
165
+ ```ts
166
+ declare const metrics: {
167
+ ok(segment: number, status: string, ms: number): void;
168
+ fail(segment: number, error: string, detail: string): void;
169
+ teardown(segment: number): void;
170
+ };
171
+
172
+ function observe(ev: CoderTransportEvent): void {
173
+ if (ev.type === "ws:redial") {
174
+ // Early warning: the server keeps generating, nothing is lost yet;
175
+ // the turn fails if this hits the cap.
176
+ console.warn(
177
+ `redial ${ev.consecutiveFailures}/${ev.maxConsecutiveFailures}, next in ${ev.backoffMs}ms`,
178
+ );
179
+ } else if (ev.type === "segment:settle") {
180
+ if (ev.finishReason) metrics.ok(ev.segment, ev.status!, ev.durationMs);
181
+ else if (ev.error) metrics.fail(ev.segment, ev.error.name, ev.error.message);
182
+ else metrics.teardown(ev.segment);
183
+ }
184
+ }
185
+ ```
186
+
187
+ `segment:settle` fires exactly once per `segment:start`, with one of three
188
+ endings:
189
+
190
+ | Ending | Fields | Means |
191
+ | ---------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
192
+ | **Clean settle** | `status` + `finishReason` | `finishReason: "tool-calls"` (status `requires_action`) is a healthy mid‑turn pause for your client tools; terminal statuses end the turn. |
193
+ | **Failure** | `error: { name, message }`, plus `status` when the server run still settled terminally | Server‑side turn errors, transport failure after exhausted redials, `requestTimeoutMs` expiry (`error.name: "CoderChatError"`), and caller aborts — named after the abort reason, never teardowns: `"TimeoutError"` for an `AbortSignal.timeout(…)` deadline, `"AbortError"` for an explicit abort. |
194
+ | **Teardown** | neither | The stream consumer itself cancelled the turn (`stream()`'s `ReadableStream.cancel()` with no abort of its own) — rare in `generate()`‑shaped steps. |
195
+
196
+ Handler exceptions are swallowed and can't disturb the turn; an unset hook
197
+ costs nothing. For fleet‑level monitoring _across_ steps, see
198
+ [Preventing stuck turns](./workspaces-and-quota.md#preventing-stuck-turns).
199
+
200
+ ## Archive at the end — and only at the end
201
+
202
+ **An archived chat cannot be resumed:** the server rejects new messages on it
203
+ (400, `Cannot send messages to an archived chat`). So skip the per‑request
204
+ [Cleanup](../README.md#cleanup) habits (`await using`, `archive()` in a
205
+ `finally`) in intermediate steps.
206
+
207
+ The _workflow_ owns the chat's end of life: archive once, in the final step and
208
+ in the failure/compensation handler, so abandoned runs don't accumulate live
209
+ chats.
210
+
211
+ ```ts
212
+ export async function archiveWorkflowChat(workflowId: string): Promise<void> {
213
+ const checkpoint = await checkpoints.get(workflowId);
214
+ if (!checkpoint) return; // the workflow died before its first turn created a chat
215
+ const agent = new CoderAgent({
216
+ baseUrl: process.env.CODER_URL!,
217
+ token: process.env.CODER_SESSION_TOKEN!,
218
+ organizationId: process.env.CODER_ORG_ID!,
219
+ chatId: checkpoint.chatId,
220
+ });
221
+ // A crashed step may have left a run live — stop it first, bounded. On an
222
+ // already-settled chat the interrupt rejects with a 409; ignore it.
223
+ await agent.interrupt({ signal: AbortSignal.timeout(15_000) }).catch(() => {});
224
+ await agent.archive(); // retries 409s while the interrupted run winds down (~15s)
225
+ }
226
+ ```
227
+
228
+ ## Let the SDK absorb drops — and handle what it re‑throws
229
+
230
+ These self‑heal, invisible to the step; the run is **not** interrupted:
231
+
232
+ - **Transient stream drops.** The server keeps generating; the SDK redials
233
+ with backoff and replays what it missed. Drops cost latency, never content,
234
+ and never duplicate content.
235
+ - **Drops during a client‑tool pause.** The turn keeps its stream across
236
+ `requires_action` pauses; a drop while your tool executes redials in the
237
+ background.
238
+ - **A lost tool‑call event.** If the pause's tool‑call event doesn't arrive
239
+ within ~2 s, the SDK recovers the pending calls from committed history over
240
+ REST and the turn continues.
241
+
242
+ <details><summary>What a redial replays</summary>
243
+
244
+ Committed messages past the turn's cursor plus the in‑progress message's
245
+ deltas, deduplicated on receipt. A message that committed _while the
246
+ connection was down_ arrives as exactly its missing tail.
247
+
248
+ </details>
249
+
250
+ Once self‑healing is exhausted, the step sees one of three errors:
251
+
252
+ ```ts
253
+ import { CoderApiError, CoderChatError, CoderStreamError } from "@coder/ai-sdk-agent";
254
+
255
+ try {
256
+ await runTurn(workflowId, prompt);
257
+ } catch (err) {
258
+ if (err instanceof CoderChatError && err.retryable) {
259
+ // Timeout or transient turn failure; the chat survives. A re-run
260
+ // resubmits the prompt as a new user turn (below).
261
+ } else if (err instanceof CoderStreamError) {
262
+ // Redial budget exhausted (~15s without forward progress). On a resumed
263
+ // chat this is never auto-retried — the retry decision is yours (below).
264
+ } else if (err instanceof CoderApiError) {
265
+ // Branch on err.status: back off and retry 408/425/429/5xx; fail the workflow
266
+ // on the rest (expired token, archived/deleted chat, …).
267
+ }
268
+ throw err;
269
+ }
270
+ ```
271
+
272
+ **Any re‑run resubmits the prompt as a new user turn.** Partial output stays in
273
+ history; tool effects that already ran are not undone. `retryable` means the
274
+ _failure_ is transient, not that a re‑run is free: retry only steps that
275
+ tolerate re‑submission, and reconcile first otherwise.
276
+
277
+ | Error | Cause | Before retrying |
278
+ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
279
+ | `CoderChatError`, `retryable: true` | `requestTimeoutMs` expired (`kind: "timeout"`) or a transient turn failure (`kind: "stream_closed"`, upstream 5xx) | Chat survives. Run the [settle‑wait](#recover-after-a-crash) before resubmitting. After a `timeout`, also pin `cutShort = true` in the [result check](#recover-the-result-before-resubmitting): the expiry already fired an interrupt. |
280
+ | `CoderStreamError` (an AI SDK `APICallError`) | The stream could not be re‑established | `isRetryable: true` only for a turn that just created its chat with no workspace, no MCP servers, and no fresh inline uploads (the SDK then discards the session). Always `false` on a resumed chat. |
281
+ | `CoderApiError` (every non‑2xx) | An HTTP request failed | Back off and retry 408, 425, 429, 5xx. Fail the workflow on 401/403/404 and the archived‑chat 400 — every attempt hits the same wall. |
282
+
283
+ <details><summary><code>CoderChatError</code>: why wait, and why pin <code>cutShort</code></summary>
284
+
285
+ The session is **not** discarded. The retried step reloads the checkpointed
286
+ chat id (the `finally` checkpoint makes this hold even when the _first_ step
287
+ fails after creating its chat) and resubmits the same prompt as a new user
288
+ turn, with the aborted attempt's partial output in history and any tool
289
+ effects that ran before the failure not undone.
290
+
291
+ Don't resubmit _immediately_: the expiry's server interrupt is asynchronous and
292
+ best‑effort, so the timed‑out run may still be committing. Run the same
293
+ interrupt‑and‑wait‑for‑terminal‑status recovery as after a crash before the
294
+ retry, or its trailing output is absorbed into the retried turn.
295
+
296
+ A timed‑out run can also have _finished_ before that interrupt landed. Because
297
+ the interrupt was already fired, the result check must treat the attempt as
298
+ cut short (pin `cutShort`) rather than trust its own 409.
299
+
300
+ </details>
301
+
302
+ <details><summary><code>CoderStreamError</code>: retry semantics and orphaned chats</summary>
303
+
304
+ When `isRetryable: true`, a retry — `maxRetries` or a re‑run step — starts
305
+ clean on a fresh chat.
306
+
307
+ **On a resumed chat it is always `isRetryable: false`.** Re‑running the step
308
+ resubmits the same prompt as a _new user turn_, with the failed attempt's
309
+ partial output still in history (usually fine — the model sees its own aborted
310
+ attempt), and any workspace/MCP tool effects that ran before the drop are not
311
+ undone. That judgment call belongs to your workflow, which is why it isn't
312
+ automatic.
313
+
314
+ **The discard happens for _every_ chat the failed turn itself created —
315
+ including the effectful, `isRetryable: false` case.** `agent.chatId` is
316
+ `undefined` and the `finally` checkpoint writes nothing, but the stranded chat
317
+ is not nameless: the error's **`chatId`** field names it, and
318
+ `interrupt()`/`archive()` keep targeting it via `agent.lastKnownChatId`
319
+ ([Cleanup](../README.md#cleanup)). The step's error path can retire the orphan
320
+ directly, or checkpoint `err.chatId` for the
321
+ [reconciliation sweep](#recover-after-a-crash) when its workspace/MCP effects
322
+ need reconciling first. A retried step then starts a fresh chat that has _not_
323
+ seen the orphan's effects: gate that retry on the same idempotency judgment as
324
+ any re‑submission.
325
+
326
+ </details>
327
+
328
+ ## Recover after a crash
329
+
330
+ A hard crash (OOM kill, host loss) runs no `finally` blocks. What to do depends
331
+ on where it hit:
332
+
333
+ | Crashed… | Left behind server‑side | Do |
334
+ | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
335
+ | **with an empty checkpoint** | Usually nothing — but _up to one unacknowledged chat per attempt may exist_; automatic retries can stack several. | Fresh chat, plus the sweep. |
336
+ | **after creation, before the checkpoint** | An orphan: generating until it settles — or, if the crash hit a client tool, paused in `requires_action` forever, holding its workspace. | Fresh chat (no id to interrupt), plus the sweep. |
337
+ | **after the checkpoint** | A resumable chat, possibly with a live run or a client‑tool pause. | Settle‑wait (below), then the [result check](#recover-the-result-before-resubmitting). |
338
+
339
+ <details><summary>Why an empty checkpoint is not a clean slate</summary>
340
+
341
+ A crash or `requestTimeoutMs` expiry while the create request was _in flight_
342
+ can leave a chat the server committed but whose id never arrived — unreachable
343
+ even by the SDK's own interrupt.
344
+
345
+ </details>
346
+
347
+ **The reconciliation sweep is not optional in this pattern.** Start from the
348
+ sweep in
349
+ [Preventing stuck turns](./workspaces-and-quota.md#preventing-stuck-turns),
350
+ with two changes:
351
+
352
+ - **Widen the filter:** sweep every chat no checkpoint accounts for —
353
+ regardless of status — and interrupt/archive it. An orphaned first turn may
354
+ settle terminally on its own and then sit live and unarchived forever; the
355
+ fleet sweep only catches _non‑terminal_ chats.
356
+ - **Retire _every_ matching orphan**, not just the first, including its
357
+ workspace/MCP effects if the deployment auto‑attached any.
358
+
359
+ <details><summary>Shrinking the window with an eager checkpoint (and what it costs)</summary>
360
+
361
+ Checkpoint eagerly from the first `ws:dial` transport event — it carries the
362
+ chat id as soon as the chat exists. The checkpoint's lifecycle is then yours
363
+ too:
364
+
365
+ - The retried step resumes a chat holding a dead half‑turn.
366
+ - Whenever a `CoderStreamError` killed a chat this step created — retryable or
367
+ not — the SDK discards the session, but only in memory. Clear the eagerly
368
+ checkpointed id yourself before any retry, or "starts fresh after a discard"
369
+ silently turns into resuming the dead chat.
370
+
371
+ </details>
372
+
373
+ **After the checkpoint, don't just resubmit.** Stop what the crash left behind,
374
+ then **wait until the chat actually settles** before deciding:
375
+
376
+ - **A client‑tool pause is reconciled, never interrupted away** — if the
377
+ tool's effect committed, the pause is its only pending record
378
+ ([Make client tools crash-safe](#make-client-tools-crash-safe)).
379
+ - **Anything else is interrupted, then polled to a terminal status:**
380
+ `interrupt()` resolves on acknowledgment, but the run keeps winding down
381
+ (and committing) for a few seconds.
382
+ - **A 409 `CoderApiError`** from the interrupt means no live run — the good
383
+ case.
384
+
385
+ <details><summary>What goes wrong if you resubmit straight away</summary>
386
+
387
+ - The crashed attempt's run may still be live. A resubmission queues behind
388
+ it: nothing the old run commits is attributed to the resumed turn (its window
389
+ opens only at its own queued prompt's materialization), but the step burns
390
+ its `requestTimeoutMs` waiting for the old run to finish first — and a
391
+ timeout withdraws the queued prompt again.
392
+ - A crash inside one of your client tools leaves the chat paused in
393
+ `requires_action`, where new messages wait forever.
394
+
395
+ </details>
396
+
397
+ ```ts
398
+ // Defined in "Make client tools crash-safe": resolves to its `unstarted`,
399
+ // true only when it fell back to interrupting.
400
+ declare function reconcileClientTools(): Promise<boolean>;
401
+ // A per-ATTEMPT journal: each entry pins the step marker and attempt count.
402
+ type JournalEntry = {
403
+ // "submitting": written ahead of every send on a checkpointed chat.
404
+ // "resumed" / "cut-short": the ledger reconcile's verdict.
405
+ state: "submitting" | "resumed" | "cut-short";
406
+ marker: string; // the step-scoped marker ("Recover the result…", below)
407
+ attempt: number; // markerAttempts() at write time
408
+ at: number; // write time — bounds the in-flight-send wait below
409
+ };
410
+ declare const journal: {
411
+ get(chatId: string): Promise<JournalEntry | undefined>;
412
+ set(chatId: string, entry: JournalEntry): Promise<void>;
413
+ };
414
+ // Count of this step's committed attempts (user messages carrying the marker).
415
+ declare function markerAttempts(): Promise<number>;
416
+
417
+ // Bounded: a stalled deployment must fail the step, not hang it.
418
+ const deadline = AbortSignal.timeout(15_000);
419
+ // Did an interrupt stop a live run? A 409 means there was nothing to stop.
420
+ let cutShort = true;
421
+ let { status } = await agent.client.getChat(agent.chatId!, deadline);
422
+ // Apply the entry only if it classifies THIS attempt of THIS step.
423
+ const entry = await journal.get(agent.chatId!);
424
+ const attempts = await markerAttempts();
425
+ const journaled = entry?.marker === marker && entry.attempt === attempts ? entry : undefined;
426
+ if (status === "requires_action") {
427
+ // A client tool crashed mid-execution: reconcile, never interrupt.
428
+ cutShort = await reconcileClientTools();
429
+ } else if (journaled && journaled.state !== "submitting") {
430
+ // A previous recovery pass acted on this attempt, then died.
431
+ cutShort = journaled.state === "cut-short";
432
+ if (cutShort) {
433
+ // The journal records intent, not a landed interrupt: re-drive it.
434
+ // A 409 means the chat already settled; the verdict stands.
435
+ await agent.interrupt({ signal: deadline }).catch((err) => {
436
+ if (!(err instanceof CoderApiError && err.status === 409)) throw err;
437
+ });
438
+ }
439
+ } else {
440
+ await agent.interrupt({ signal: deadline }).catch((err) => {
441
+ // A probed "interrupting" means an earlier interrupt already truncated
442
+ // the attempt; the 409 just says it landed — keep cutShort = true.
443
+ if (err instanceof CoderApiError && err.status === 409) cutShort = status === "interrupting";
444
+ else throw err;
445
+ });
446
+ }
447
+ // The crashed attempt's own send may still land: sleep out its commit
448
+ // window, then recount.
449
+ if (journaled?.state === "submitting") {
450
+ await new Promise((r) => setTimeout(r, Math.max(0, journaled.at + 300_000 - Date.now())));
451
+ if ((await markerAttempts()) === journaled.attempt && journaled.attempt > 0) cutShort = true;
452
+ }
453
+ // Turn-scale budget: a reconciled pause revives a full model/tool continuation.
454
+ const settle = AbortSignal.timeout(600_000);
455
+ do {
456
+ ({ status } = await agent.client.getChat(agent.chatId!, settle));
457
+ if (status === "waiting" || status === "completed" || status === "error") break;
458
+ // Stable pause: the resumed run called a NEW client tool — reconcile again.
459
+ if (status === "requires_action" && !cutShort) break;
460
+ await new Promise((r) => setTimeout(r, 1_000)); // acknowledged ≠ settled — wait it out
461
+ } while (true);
462
+ ```
463
+
464
+ Settled is necessary, not sufficient: the crashed attempt may already have
465
+ _finished the step's work_.
466
+ [Check history before resubmitting](#recover-the-result-before-resubmitting).
467
+
468
+ <details><summary>How the settle‑wait decides, branch by branch</summary>
469
+
470
+ **Journal scoping.** Entries are per attempt, so the chat id alone cannot scope
471
+ them: a verdict from attempt N must not classify attempt N+1 (double crash),
472
+ and a later step's recovery on the same chat must not inherit this one's. A
473
+ consult applies an entry only while both marker and attempt count still match,
474
+ so each resubmission retires every earlier verdict by itself — there is no
475
+ clear step to forget. The ledger reconciliation journals each action
476
+ write‑ahead, so a recovery pass that dies mid‑recovery is visible — and
477
+ classifiable — to the next.
478
+
479
+ **`markerAttempts()`** counts user messages in the converted transcript
480
+ carrying the marker. Steps run sequentially, so they sit contiguous at the tail
481
+ of history: page newest‑first (as the result scan does) and stop at the first
482
+ user message without the marker.
483
+
484
+ **`requires_action`.** The crash hit a client tool mid‑execution, and this
485
+ pause is the only pending record of its committed effects. Reconciliation
486
+ submits recorded results (reviving the run — nothing was cut short) or
487
+ interrupts itself only when nothing committed.
488
+
489
+ **Journaled `"resumed"`.** The previous pass revived the run by submitting
490
+ recorded results. Interrupting now would cut down a run doing the step's work,
491
+ and the resubmitted prompt would mint _new_ tool‑call ids, sidestepping the
492
+ idempotency keys. Let it finish; nothing was cut short.
493
+
494
+ **Journaled `"cut-short"`.** The previous pass went on to interrupt (an
495
+ unstarted sibling in the batch), so the attempt is truncated and the result
496
+ check must resubmit. The journal records write‑ahead _intent_, not a landed
497
+ interrupt — the pass may have died in between, leaving the revived run working
498
+ — so the interrupt is re‑driven to make the intent true.
499
+
500
+ **A 409 on the fresh interrupt.** A 409 proves nothing is stoppable _now_. That
501
+ means "never cut short" only if the probe saw no interrupt already in flight. A
502
+ probed `"interrupting"` is an earlier interrupt (a timeout's best‑effort one, a
503
+ sweeper) already truncating the attempt.
504
+
505
+ **A matching `"submitting"` entry** — its attempt count has not grown — means
506
+ the process died with `createChatMessage` possibly en route, and the server can
507
+ commit it _after_ every read above (the 409 only said nothing was running
508
+ then). The wait sleeps out what remains of the entry's commit window:
509
+ `requestTimeoutMs` past the write is the conservative horizon, and a restart
510
+ has usually consumed it already. A late‑landing turn then starts before the
511
+ settle poll, and the next section's negative history read is past the window.
512
+
513
+ **An unlanded send over an existing attempt pins `cutShort`.** The step only
514
+ ever plans a resubmission over a cut‑short, errored, or unsubmitted attempt, so
515
+ the write‑ahead that superseded a `"cut-short"` verdict still carries its
516
+ consequence, and the result scan cannot mistake the old attempt's truncated
517
+ tail for a finished result.
518
+
519
+ **Two budgets.** The 15 s interrupt bound must not cap what follows: a
520
+ reconciled pause revives a full model/tool continuation. The settle poll — and
521
+ the history reads after it — are budgeted on the turn scale, like the step
522
+ itself.
523
+
524
+ **`requires_action` inside the poll.** With no accepted interrupt racing to
525
+ clear it, `requires_action` is a _stable_ pause: a run resumed by submitted
526
+ tool results called a new client tool. Exit and reconcile the ledger again
527
+ instead of polling out the deadline.
528
+
529
+ </details>
530
+
531
+ ## Make client tools crash-safe
532
+
533
+ If the process dies _between_ a non‑idempotent client tool committing its
534
+ effect (a payment, a deploy, an email) and the result reaching the server, the
535
+ `requires_action` pause is the **only pending record of the execution**.
536
+ Interrupt it away, and the resubmitted prompt may call the tool again —
537
+ duplicating the effect.
538
+
539
+ Fix: a **tool‑invocation ledger** in the same durable store as the `chatId`
540
+ checkpoint, keyed by **chat id + `toolCallId`**. The server assigns the call id
541
+ and commits it to history, so both sides survive the crash.
542
+
543
+ <details><summary>Why the chat id (and tenant) belong in the key</summary>
544
+
545
+ Call ids are only unique _within_ a chat. Put the chat id in the ledger key and
546
+ in the idempotency key handed to the external system: a store shared across
547
+ workflows must never let one chat's `done` entry answer — or deduplicate —
548
+ another chat's call. In multi‑tenant stores, fold the tenant in too.
549
+
550
+ </details>
551
+
552
+ ```ts
553
+ import { tool } from "ai";
554
+ import { z } from "zod";
555
+ import { CoderAgent } from "@coder/ai-sdk-agent";
556
+
557
+ type LedgerEntry = { state: "committing" } | { state: "done"; output: string };
558
+ declare const ledger: {
559
+ get(key: string): Promise<LedgerEntry | undefined>;
560
+ set(key: string, entry: LedgerEntry): Promise<void>;
561
+ };
562
+ declare const payments: {
563
+ charge(amountCents: number, opts: { idempotencyKey: string }): Promise<{ receiptId: string }>;
564
+ };
565
+
566
+ const ledgerKey = (chatId: string, toolCallId: string) => `${chatId}/${toolCallId}`;
567
+ const ChargeArgs = z.object({ amountCents: z.number().int() });
568
+
569
+ // Takes a getter: the chat may not exist until the turn creates it, but its
570
+ // id is set before any tool runs.
571
+ const chargeCard = (chatId: () => string) =>
572
+ tool({
573
+ description: "Charge the customer's card.",
574
+ inputSchema: ChargeArgs, // shared with recovery's re-drive dispatch below
575
+ execute: async ({ amountCents }, { toolCallId }) => {
576
+ const key = ledgerKey(chatId(), toolCallId);
577
+ // Write-ahead, BEFORE the effect: no entry proves the effect never started.
578
+ await ledger.set(key, { state: "committing" });
579
+ // The SAME key as the idempotency key: re-driving can never double-charge.
580
+ const { receiptId } = await payments.charge(amountCents, { idempotencyKey: key });
581
+ await ledger.set(key, { state: "done", output: `charged: receipt ${receiptId}` });
582
+ return `charged: receipt ${receiptId}`;
583
+ },
584
+ });
585
+
586
+ // A direct self-reference in the constructor call is circular for the type
587
+ // checker — defer it through a box assigned right after.
588
+ let self: { chatId?: string } | undefined;
589
+ const agent = new CoderAgent({
590
+ baseUrl: process.env.CODER_URL!,
591
+ token: process.env.CODER_SESSION_TOKEN!,
592
+ organizationId: process.env.CODER_ORG_ID!,
593
+ // …plus chatId, requestTimeoutMs, etc. as in runTurn
594
+ tools: { charge_card: chargeCard(() => self!.chatId!) },
595
+ });
596
+ self = agent;
597
+ ```
598
+
599
+ On recovery, reconcile a `requires_action` pause _before_ any interrupt. The
600
+ pending calls are in committed history; the ledger holds the verdict on each:
601
+
602
+ | Ledger entry | Meaning | Action |
603
+ | ------------ | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
604
+ | `done` | Effect committed, result stranded. | Submit the recorded output; nothing re‑executes. |
605
+ | `committing` | Ambiguous: crashed between the write‑ahead and `done`. | Re‑drive with the _same_ idempotency key (a committed effect replays as a no‑op), args from history, validated as the live loop would. |
606
+ | none | The tool never ran. | Interrupting loses nothing. |
607
+
608
+ This is the derivation the SDK uses to recover a lost `action_required` event:
609
+
610
+ ```ts
611
+ // History does not record which calls were client tools (see [Rehydrating
612
+ // chat history]), so restrict to the names this step registers: a server/MCP
613
+ // call must never reach the ledger as "unstarted". The result scan's
614
+ // final-text cut reuses this set (next section).
615
+ const clientTools = new Set(["charge_card"]);
616
+
617
+ // Pending = the last assistant message's client (non-provider-executed,
618
+ // ToolSet-named) tool calls, minus ids answered by a later message's tool
619
+ // result.
620
+ const { messages } = await agent.client.getMessages(agent.chatId!, { limit: 200 }, deadline);
621
+ const ordered = [...messages].sort((a, b) => a.id - b.id);
622
+ const lastAssistant = ordered.findLast((m) => m.role === "assistant");
623
+ const handled = new Set(
624
+ ordered
625
+ .filter((m) => m.id > (lastAssistant?.id ?? 0))
626
+ .flatMap((m) => m.content ?? [])
627
+ .filter((p) => p.type === "tool-result")
628
+ .map((p) => p.tool_call_id),
629
+ );
630
+ const pending = (lastAssistant?.content ?? []).filter(
631
+ (p) =>
632
+ p.type === "tool-call" &&
633
+ !p.provider_executed &&
634
+ clientTools.has(p.tool_name!) &&
635
+ !handled.has(p.tool_call_id),
636
+ );
637
+
638
+ const results: { tool_call_id: string; output: unknown }[] = [];
639
+ let unstarted = false;
640
+ for (const call of pending) {
641
+ const key = ledgerKey(agent.chatId!, call.tool_call_id!);
642
+ const entry = await ledger.get(key);
643
+ if (entry?.state === "done") {
644
+ results.push({ tool_call_id: call.tool_call_id!, output: entry.output });
645
+ } else if (entry) {
646
+ // "committing": re-drive. Every ledgered tool needs its own path.
647
+ if (call.tool_name !== "charge_card")
648
+ throw new Error(`no re-drive path for tool ${call.tool_name}`);
649
+ const { amountCents } = ChargeArgs.parse(call.args);
650
+ const { receiptId } = await payments.charge(amountCents, { idempotencyKey: key });
651
+ const output = `charged: receipt ${receiptId}`;
652
+ await ledger.set(key, { state: "done", output });
653
+ results.push({ tool_call_id: call.tool_call_id!, output });
654
+ } else {
655
+ unstarted = true; // the tool never ran — interrupting loses nothing
656
+ }
657
+ }
658
+ if (results.length > 0 || unstarted) {
659
+ // ONE write-ahead with the batch's FINAL verdict, before any network call.
660
+ await journal.set(agent.chatId!, {
661
+ state: unstarted ? "cut-short" : "resumed",
662
+ marker,
663
+ attempt: await markerAttempts(),
664
+ at: Date.now(),
665
+ });
666
+ }
667
+ if (results.length > 0) await agent.client.submitToolResults(agent.chatId!, { results }, deadline);
668
+ if (unstarted) await agent.client.interruptChat(agent.chatId!, deadline);
669
+ // As `reconcileClientTools()` in the settle-wait snippet above: resolve to
670
+ // `unstarted` — cutShort is true only when this fell back to interrupting.
671
+ ```
672
+
673
+ - **Only interrupt when the ledger shows no committed effect.** Submitted
674
+ results are committed to history, so even a mixed batch (one call done, a
675
+ sibling never started) that ends in an interrupt keeps the effect's recorded
676
+ outcome for the resubmitted turn.
677
+ - **Submitting answers the pause; it does not revive your tool loop.** The turn
678
+ resumes **server‑side** with no process streaming it. Its output lands only
679
+ in history — [recover it](#recover-the-result-before-resubmitting) instead
680
+ of resubmitting.
681
+
682
+ <details><summary>How reconciliation interacts with the settle‑wait</summary>
683
+
684
+ - **Why the settle‑wait's reconcile branch never interrupts a pause:** nothing
685
+ stranded is left to stop, and an interrupt now would cut down the very run
686
+ the submission revived.
687
+ - **Why `unstarted` leaves `cutShort = false` after a submission with no
688
+ unstarted call:** nothing was cut short, and the next section's `!cutShort`
689
+ check must classify the resumed run's finish as a completed attempt, not
690
+ resubmit it.
691
+ - **The settle poll uses a turn‑scale deadline**, not the 15 s interrupt bound.
692
+ It exits on `requires_action` (stable here, with no accepted interrupt
693
+ clearing it), which means the resumed run called a _new_ tool — reconcile
694
+ again; a call with no ledger entry is safe to interrupt.
695
+ - **One batch verdict, journaled first.** A mixed batch journals `"cut-short"`
696
+ before even its submission, so no crash point can leave a stale `"resumed"`
697
+ masking the pending interrupt. Every crash then either re‑reconciles the
698
+ stable pause or re‑drives the journaled intent. The verdict is scoped to this
699
+ attempt: a resubmission grows the marker count and retires it on its own.
700
+ - **Tool write‑ahead.** A crash between the `committing` and `done` writes
701
+ leaves `committing` (ambiguous, but resolvable by re‑driving); no entry at
702
+ all proves the effect never started.
703
+
704
+ </details>
705
+
706
+ ## Recover the result before resubmitting
707
+
708
+ The last crash window: **the server finished the turn, but the process died
709
+ before recording the step's result.** A blind resubmit runs the whole turn
710
+ again — duplicated output, repeated workspace/MCP/tool effects.
711
+
712
+ **First, make attempts matchable.** The server stores the prompt verbatim:
713
+ embed a step‑scoped marker, and journal every send before it leaves the
714
+ process.
715
+
716
+ ```ts
717
+ const marker = `[${workflowId}/step-2]`; // stable across this step's retries
718
+ // Write-ahead, BEFORE the send — first attempt and recovery resubmit alike.
719
+ if (agent.chatId) {
720
+ await journal.set(agent.chatId, {
721
+ state: "submitting",
722
+ marker,
723
+ attempt: await markerAttempts(),
724
+ at: Date.now(),
725
+ });
726
+ }
727
+ const { text } = await agent.generate({ prompt: `${marker} ${prompt}` });
728
+ ```
729
+
730
+ <details><summary>What the send write‑ahead guarantees</summary>
731
+
732
+ - A crash before this write proves no send ever started. A crash after it
733
+ leaves a dated entry whose commit window the settle‑wait sleeps out.
734
+ - On a resubmission this write supersedes the `"cut-short"` verdict it acts on
735
+ — safely: an unlanded send over an existing attempt makes the settle‑wait pin
736
+ `cutShort` right back.
737
+ - A first turn has no chat id to journal under. Its send‑crash window belongs
738
+ to the empty‑checkpoint sweep ([Recover after a crash](#recover-after-a-crash)).
739
+
740
+ </details>
741
+
742
+ **Then, after the settle‑wait,** classify the attempt:
743
+
744
+ | State | Condition | Action |
745
+ | ------------------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
746
+ | Finished, recording lost | `submitted && !cutShort`, status `waiting` or `completed` | Return the recovered result. |
747
+ | Never finished | `!submitted`, status `"error"`, or `cutShort` | Resubmit. `!submitted` is sound only behind the write‑ahead; `"error"` gets normal retry judgment; after `cutShort` the model continues from its aborted attempt in history. |
748
+ | Paused | settled `requires_action` | Back to ledger reconciliation ([previous section](#make-client-tools-crash-safe)) — never recovery or resubmission. |
749
+
750
+ ```ts
751
+ import { chatMessagesToUIMessages } from "@coder/ai-sdk-agent";
752
+
753
+ // A long turn can commit more than a page after the prompt: page newest-first
754
+ // (the endpoint's default order) until the turn's user message is on hand.
755
+ // "Never submitted" must never be concluded from a truncated read.
756
+ const fetched = [];
757
+ let page = await agent.client.getMessages(agent.chatId!, { limit: 200 }, settle);
758
+ fetched.push(...page.messages);
759
+ while (!fetched.some((m) => m.role === "user") && page.has_more) {
760
+ const oldestId = Math.min(...fetched.map((m) => m.id));
761
+ page = await agent.client.getMessages(agent.chatId!, { before_id: oldestId, limit: 200 }, settle);
762
+ fetched.push(...page.messages);
763
+ }
764
+ const transcript = chatMessagesToUIMessages(fetched); // chronological
765
+ const lastUser = transcript.findLast((m) => m.role === "user");
766
+ const submitted = lastUser?.parts.some((p) => p.type === "text" && p.text.includes(marker));
767
+
768
+ if (submitted && !cutShort && (status === "waiting" || status === "completed")) {
769
+ // Recover what `generate().text` would have returned: the FINAL step's
770
+ // text, after the last configured client-tool part.
771
+ const parts = transcript
772
+ .slice(transcript.indexOf(lastUser!) + 1)
773
+ .filter((m) => m.role === "assistant")
774
+ .flatMap((m) => m.parts);
775
+ return parts
776
+ .slice(parts.findLastIndex((p) => p.type === "dynamic-tool" && clientTools.has(p.toolName)) + 1)
777
+ .filter((p) => p.type === "text")
778
+ .map((p) => p.text)
779
+ .join("");
780
+ }
781
+ // Otherwise resubmit — unless the settle was requires_action (table above).
782
+ ```
783
+
784
+ - **`cutShort` separates completed from interrupted; history can't** (both
785
+ leave the marker and an answer). A 409 only proves nothing is running _now_:
786
+ pin `cutShort = true` when recovery follows a known timeout or abort, and let
787
+ only the hard‑crash path trust the 409. The settle‑wait already pins it for a
788
+ matching journaled `"cut-short"` or an unlanded `"submitting"` over an
789
+ existing attempt.
790
+ - **`!submitted` races the crashed attempt's own send.** The write‑ahead
791
+ narrows that window; it cannot close it.
792
+ - **The text cut lands at the last _configured client‑tool_ boundary.** Only a
793
+ client tool ends an AI SDK step, so narration before a trailing
794
+ _server‑side_ call survives, matching what `generate().text` aggregates.
795
+
796
+ <details><summary>Why a 409 cannot prove the attempt finished after a timeout</summary>
797
+
798
+ The inference "409 ⇒ the attempt finished" is sound only when recovery's
799
+ interrupt is the _first_ one that could have stopped the run: true after a hard
800
+ crash, false after a `requestTimeoutMs` expiry. The expiry already fired the
801
+ SDK's own best‑effort interrupt, which may have stopped the run before recovery
802
+ ever looked. So carry the reason recovery ran.
803
+
804
+ </details>
805
+
806
+ <details><summary>The remaining duplicate‑send window</summary>
807
+
808
+ This is the at‑least‑once boundary every durable engine has around external
809
+ effects; the chat itself is the record that narrows it to one honest window.
810
+
811
+ A crash kills the process, not necessarily its in‑flight `createChatMessage`.
812
+ The server can commit that request _after_ an interrupt 409'd and a history
813
+ read saw no marker, and a resubmit on that evidence queues the turn twice,
814
+ duplicating its workspace/MCP/tool effects.
815
+
816
+ The write‑ahead makes the negative read honest: no matching `"submitting"`
817
+ entry proves no send ever started, and a matching one held recovery at the
818
+ settle‑wait until the entry's commit horizon passed. Only server‑enforced
819
+ submission idempotency could close the window, and
820
+ `POST /chats/{id}/messages` offers none today. A commit that outlasts even the
821
+ horizon still lands under the same marker, so the duplicate is at least
822
+ visible — in history, and to the next consult's attempt count.
823
+
824
+ </details>
825
+
826
+ <details><summary>Where name‑matching the client‑tool cut goes wrong</summary>
827
+
828
+ Narration lands on both sides of a server‑side call, and history does not mark
829
+ which calls were client tools
830
+ ([Rehydrating chat history](../README.md#rehydrating-chat-history)). So the
831
+ cut classifies by name against the reconcile's `clientTools` set. It cannot
832
+ see:
833
+
834
+ - A call recorded under a name the step no longer configures (a tool renamed
835
+ between deploys) reads as server‑side and leaks earlier‑step narration into
836
+ the result.
837
+ - A server tool sharing a configured name still cuts final‑step narration
838
+ away.
839
+
840
+ </details>
841
+
842
+ <details><summary>Structured‑output steps: recover the call's input, not the text</summary>
843
+
844
+ A [structured output](./structured-output.md) step's answer is the
845
+ `structured_output` call's typed input, which rehydrates as a `dynamic-tool`
846
+ part on the recovered turn
847
+ ([Rehydrating chat history](../README.md#rehydrating-chat-history)).
848
+ `structured_output` is one of the step's configured client tools, so the text
849
+ join above cuts at it and returns only the ack prose that follows. Recover the
850
+ filed call instead: this scan replaces the text join _inside_ the recovery
851
+ branch above, reusing its `parts`, and validates client‑side exactly like the
852
+ live path ([rule 2](./structured-output.md#2-validate-clientside) — the schema is the real gate):
853
+
854
+ ```ts
855
+ // Scan backward to the last call that VALIDATES, as in the live path — a
856
+ // schema-invalid re-file must not shadow a valid answer.
857
+ const filed = parts
858
+ .filter((p) => p.type === "dynamic-tool")
859
+ .filter((p) => p.toolName === "structured_output");
860
+ let answer: z.infer<typeof Answer> | undefined;
861
+ for (const call of filed.reverse()) {
862
+ const parsed = Answer.safeParse(call.input);
863
+ if (parsed.success) {
864
+ answer = parsed.data;
865
+ break;
866
+ }
867
+ }
868
+ // recovered ⇔ answer !== undefined
869
+ ```
870
+
871
+ </details>
872
+
873
+ ## Checklist
874
+
875
+ - One turn per durable step; `generate()`, not `stream()`.
876
+ - Persist `agent.chatId` (a string) — never the instance, never the token.
877
+ - Bound every step: `requestTimeoutMs` per segment, an abort deadline for total
878
+ wall‑clock.
879
+ - Let redials self‑heal; own every retry decision — a re‑run step resubmits its
880
+ prompt as a new user turn.
881
+ - Before resubmitting after a crash: reconcile the tool ledger before any
882
+ interrupt, recover a finished attempt's result from history, and trust "never
883
+ submitted" only past the journaled send's commit window.
884
+ - Keep concurrent steps' fan‑out within workspace quota —
885
+ [Workspaces & quota](./workspaces-and-quota.md).
886
+ - Steps that don't need server‑side tools (plan / extract / synthesize) are
887
+ cheaper and natively structured through
888
+ [`@coder/ai-sdk-provider`](https://github.com/coder/ai-sdk/tree/main/packages/provider) + `generateObject` — no chat, no
889
+ workspace, no cleanup.
890
+ - Archive in the final step / failure handler — never per step.