@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.
- package/README.md +513 -1545
- package/docs/durable-workflows.md +890 -0
- package/docs/observability.md +130 -0
- package/docs/structured-output.md +134 -0
- package/docs/workspaces-and-quota.md +214 -0
- package/package.json +2 -1
|
@@ -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.
|