experimental-a2 0.17.0 → 0.18.1

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.
Files changed (221) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
  11. package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  12. package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
  13. package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +692 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
  24. package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
  25. package/dist/ai-harness-BuLh_EYi.js +273 -0
  26. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +950 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +113 -40
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  41. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  42. package/dist/ai.d.ts +1 -1
  43. package/dist/ai.js +1 -1
  44. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  45. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  46. package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
  47. package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
  48. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  49. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  50. package/dist/client.d.ts +3 -3
  51. package/dist/client.js +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  54. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  55. package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
  56. package/dist/internal-CscSY7K_.js.map +1 -0
  57. package/dist/otel.d.ts +1 -1
  58. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  59. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  60. package/dist/react.d.ts +1 -1
  61. package/dist/react.js +1 -1
  62. package/dist/reducer-BAXc8JyF.js.map +1 -1
  63. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  64. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-qstash.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +7 -7
  69. package/dist/scheduler-vercel.d.ts.map +1 -1
  70. package/dist/scheduler-vercel.js +9 -10
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
  73. package/dist/server-D-Y4pqwu.js.map +1 -0
  74. package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
  75. package/dist/server-DYAATFuT.d.ts.map +1 -0
  76. package/dist/server.d.ts +4 -4
  77. package/dist/server.js +1 -1
  78. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  79. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  80. package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
  81. package/dist/store-CyLUy86v.d.ts.map +1 -0
  82. package/dist/store-N8PXxDAS.js.map +1 -1
  83. package/dist/store-memory.d.ts +1 -1
  84. package/dist/store-memory.js +56 -11
  85. package/dist/store-memory.js.map +1 -1
  86. package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
  87. package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  88. package/dist/store-postgres.d.ts +1 -1
  89. package/dist/store-postgres.d.ts.map +1 -1
  90. package/dist/store-postgres.js +106 -25
  91. package/dist/store-postgres.js.map +1 -1
  92. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
  93. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  94. package/dist/store-redis-http.d.ts +1 -1
  95. package/dist/store-redis-http.js +3 -3
  96. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
  97. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  98. package/dist/store-redis.d.ts +1 -1
  99. package/dist/store-redis.js +1 -1
  100. package/dist/store-sqlite.d.ts +1 -1
  101. package/dist/store-sqlite.js +66 -21
  102. package/dist/store-sqlite.js.map +1 -1
  103. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  104. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  105. package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
  106. package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
  107. package/docs/01-quickstart.mdx +49 -83
  108. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  109. package/docs/actors/01-introduction.mdx +48 -86
  110. package/docs/actors/02-concurrency.mdx +43 -70
  111. package/docs/actors/03-timers.mdx +28 -42
  112. package/docs/actors/04-routes.mdx +110 -150
  113. package/docs/actors/meta.ts +1 -1
  114. package/docs/agents/01-introduction.mdx +257 -0
  115. package/docs/agents/02-tools.mdx +247 -0
  116. package/docs/agents/03-conversations.mdx +538 -0
  117. package/docs/agents/04-harnesses.mdx +136 -0
  118. package/docs/agents/meta.ts +1 -0
  119. package/docs/client/01-react.mdx +294 -0
  120. package/docs/client/02-transports.mdx +86 -0
  121. package/docs/client/03-presence.mdx +224 -0
  122. package/docs/client/meta.ts +1 -0
  123. package/docs/contract/01-events.mdx +57 -0
  124. package/docs/contract/02-state.mdx +142 -0
  125. package/docs/contract/03-sessions.mdx +175 -0
  126. package/docs/index.mdx +109 -145
  127. package/docs/reference/01-core.mdx +139 -0
  128. package/docs/reference/02-server.mdx +860 -0
  129. package/docs/reference/03-client.mdx +301 -0
  130. package/docs/reference/04-ai.mdx +731 -0
  131. package/docs/reference/05-adapters.mdx +351 -0
  132. package/docs/reference/06-errors.mdx +138 -0
  133. package/docs/reference/meta.ts +1 -1
  134. package/docs/server/01-handlers.mdx +200 -0
  135. package/docs/server/02-timers.mdx +130 -0
  136. package/docs/server/03-cancellation.mdx +142 -0
  137. package/docs/server/04-routes.mdx +243 -0
  138. package/docs/server/05-production.mdx +323 -0
  139. package/docs/server/06-guarantees.mdx +132 -0
  140. package/examples/playground/DEPLOY.md +3 -5
  141. package/examples/playground/app/a0/[appId]/page.tsx +12 -13
  142. package/examples/playground/app/a0/app-list.tsx +9 -2
  143. package/examples/playground/app/a0/app-view.tsx +9 -1
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  145. package/examples/playground/app/a0/apps.test.ts +12 -0
  146. package/examples/playground/app/a0/apps.ts +22 -7
  147. package/examples/playground/app/a0/chat.tsx +47 -1
  148. package/examples/playground/app/a0/devtools.tsx +2 -8
  149. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  150. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/model.test.ts +18 -1
  153. package/examples/playground/app/a0/model.ts +47 -10
  154. package/examples/playground/app/a0/no-photon.cjs +4 -0
  155. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  156. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  157. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  158. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  159. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  160. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  161. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  162. package/examples/playground/app/a0/server.ts +98 -54
  163. package/examples/playground/app/a0/session.ts +17 -5
  164. package/examples/playground/app/a0/shell.tsx +11 -3
  165. package/examples/playground/app/agent/ingress.test.ts +6 -3
  166. package/examples/playground/app/agent/server.ts +9 -6
  167. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  168. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  169. package/examples/playground/app/chat/server.test.ts +30 -26
  170. package/examples/playground/app/chat/server.ts +46 -46
  171. package/examples/playground/next.config.ts +17 -5
  172. package/examples/playground/package.json +4 -1
  173. package/examples/playground/vercel.json +18 -0
  174. package/package.json +27 -3
  175. package/src/ai-agent.ts +271 -90
  176. package/src/ai-fx.browser.ts +5 -0
  177. package/src/ai-fx.ts +912 -0
  178. package/src/ai-harness.ts +518 -0
  179. package/src/ai-model-metadata.ts +11 -6
  180. package/src/ai-pi.browser.ts +5 -0
  181. package/src/ai-pi.ts +1322 -0
  182. package/src/ai-sdk-step.ts +2 -2
  183. package/src/ai-server.ts +3 -1
  184. package/src/ai-tool-input.ts +45 -0
  185. package/src/internal.ts +13 -0
  186. package/src/libfx.d.ts +34 -0
  187. package/src/reducer.ts +1 -1
  188. package/src/scheduler-qstash.ts +5 -5
  189. package/src/scheduler-vercel.ts +14 -15
  190. package/src/server.ts +233 -29
  191. package/src/store-memory.ts +92 -16
  192. package/src/store-postgres.ts +156 -25
  193. package/src/store-redis-core.ts +322 -110
  194. package/src/store-redis-notify.ts +2 -0
  195. package/src/store-sqlite.ts +121 -28
  196. package/src/store.ts +55 -3
  197. package/src/telemetry.ts +1 -0
  198. package/dist/internal-CbVvEwad.js.map +0 -1
  199. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  200. package/dist/server-Rj64fNvX.js.map +0 -1
  201. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  202. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  203. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  204. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  205. package/docs/concepts/01-contracts.mdx +0 -237
  206. package/docs/concepts/02-handlers.mdx +0 -284
  207. package/docs/concepts/03-durability.mdx +0 -314
  208. package/docs/concepts/04-state.mdx +0 -209
  209. package/docs/guides/01-timers.mdx +0 -193
  210. package/docs/guides/02-cancellation.mdx +0 -161
  211. package/docs/guides/03-react.mdx +0 -324
  212. package/docs/guides/04-local-first.mdx +0 -47
  213. package/docs/guides/05-production.mdx +0 -534
  214. package/docs/guides/06-ai-agents.mdx +0 -1408
  215. package/docs/guides/08-application-data.mdx +0 -113
  216. package/docs/guides/09-presence.mdx +0 -262
  217. package/docs/guides/10-transports.mdx +0 -221
  218. package/docs/reference/01-api.mdx +0 -2358
  219. package/docs/reference/02-errors.mdx +0 -150
  220. /package/docs/{concepts → contract}/meta.ts +0 -0
  221. /package/docs/{guides → server}/meta.ts +0 -0
@@ -8,7 +8,6 @@ import type {
8
8
  FinishReason,
9
9
  InferUIMessageChunk,
10
10
  Instructions,
11
- LanguageModel,
12
11
  LanguageModelUsage,
13
12
  ModelMessage,
14
13
  TextStreamPart,
@@ -125,7 +124,8 @@ export type AISDKStepSettings<T extends ToolSet> = Partial<
125
124
  }
126
125
 
127
126
  export type AISDKStepInput<M extends UIMessage, T extends ToolSet> = {
128
- model: LanguageModel
127
+ /** An AI Gateway model ID, resolved through the AI SDK's global provider. */
128
+ model: string
129
129
  tools: T
130
130
  messages: M[]
131
131
  modelMessages?: ModelMessage[]
package/src/ai-server.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  export { agent, runWithHandlerContext } from './ai-agent.ts'
8
8
  export type {
9
+ AISDKHarness,
9
10
  Agent,
10
11
  AgentFromContractOptions,
11
12
  AgentGenerate,
@@ -19,8 +20,9 @@ export type {
19
20
  CompactionOptions,
20
21
  CompactionPolicy,
21
22
  Instructions,
22
- LanguageModel,
23
23
  ModelSettings,
24
24
  Resolvable,
25
+ ToolRecovery,
25
26
  ToolSet,
26
27
  } from './ai-agent.ts'
28
+ export type { Harness } from './ai-harness.ts'
@@ -0,0 +1,45 @@
1
+ import {
2
+ asSchema,
3
+ InvalidToolInputError,
4
+ type ToolSet,
5
+ TypeValidationError,
6
+ } from 'ai'
7
+
8
+ export type SchemaInput =
9
+ { success: true; value: unknown } | { success: false; errorText: string }
10
+
11
+ /**
12
+ * A call's input through its tool's schema, as the AI SDK step reads it: the
13
+ * schema's output with its defaults, or the AI SDK's error text. A name the
14
+ * tool set does not own has no schema.
15
+ */
16
+ export async function schemaInput({
17
+ tools,
18
+ toolName,
19
+ value,
20
+ }: {
21
+ tools: ToolSet
22
+ toolName: string
23
+ value: unknown
24
+ }): Promise<SchemaInput> {
25
+ const schema = Object.hasOwn(tools, toolName)
26
+ ? asSchema(tools[toolName]!.inputSchema)
27
+ : undefined
28
+ if (schema?.validate === undefined) return { success: true, value }
29
+ let cause: unknown
30
+ try {
31
+ const result = await schema.validate(value)
32
+ if (result.success) return { success: true, value: result.value }
33
+ cause = result.error
34
+ } catch (error) {
35
+ cause = error
36
+ }
37
+ return {
38
+ success: false,
39
+ errorText: new InvalidToolInputError({
40
+ toolName,
41
+ toolInput: JSON.stringify(value),
42
+ cause: TypeValidationError.wrap({ value, cause }),
43
+ }).message,
44
+ }
45
+ }
package/src/internal.ts CHANGED
@@ -73,6 +73,13 @@ export type ServerInternals = {
73
73
 
74
74
  export const serverInternals: WeakMap<object, ServerInternals> = new WeakMap()
75
75
 
76
+ /**
77
+ * Handler contexts whose previous attempt ended in a failure the handler
78
+ * threw, as opposed to a claim that lapsed (a crash, a timeout, a frozen
79
+ * process). Only the attempt's own outcome is known, not how far it got.
80
+ */
81
+ export const failedPreviousAttempt: WeakSet<object> = new WeakSet()
82
+
76
83
  const objectLike = (value: unknown): value is object =>
77
84
  (typeof value === 'object' && value !== null) || typeof value === 'function'
78
85
 
@@ -267,12 +274,18 @@ export const DRAIN_TIMINGS: {
267
274
  watchdogGraceMs: number
268
275
  /** Maximum time a top-level append waits for its initial scheduler arm. */
269
276
  schedulerArmTimeoutMs: number
277
+ /** First retry delay after a caught failure, doubling per failure. */
278
+ failureRetryBaseMs: number
279
+ /** Ceiling of the caught-failure retry delay. */
280
+ failureRetryMaxMs: number
270
281
  } = {
271
282
  claimTtlMs: 5_000,
272
283
  claimHeartbeatMs: 2_000,
273
284
  renewRetryMs: 400,
274
285
  watchdogGraceMs: 1_000,
275
286
  schedulerArmTimeoutMs: 2_000,
287
+ failureRetryBaseMs: 1_000,
288
+ failureRetryMaxMs: 30_000,
276
289
  }
277
290
 
278
291
  /**
package/src/libfx.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ // The subset of libfx's agent API that experimental-a2/ai/fx uses; libfx
2
+ // ships no type declarations.
3
+ declare module 'libfx' {
4
+ export type FxHostTool = {
5
+ name: string
6
+ description: string
7
+ inputSchema: object
8
+ execute(input: unknown, context: { signal: AbortSignal }): unknown
9
+ }
10
+
11
+ export type FxTurn = AsyncIterable<{ type: string }> & {
12
+ result: Promise<{ stopReason: string }>
13
+ steer(input: string): Promise<void>
14
+ cancel(): void
15
+ }
16
+
17
+ export type FxAgent = {
18
+ prompt(input: string, options?: { signal?: AbortSignal }): FxTurn
19
+ checkpoint(): Promise<Uint8Array>
20
+ close(): Promise<void>
21
+ }
22
+
23
+ export function createFxAgent(options: {
24
+ apiKey: string
25
+ model?: string | { id: string; effort?: string; fast?: boolean }
26
+ instructions?: string | string[]
27
+ tools?: FxHostTool[]
28
+ checkpoint?: Uint8Array
29
+ fetch?: (
30
+ input: string | URL | Request,
31
+ init?: RequestInit,
32
+ ) => Promise<Response>
33
+ }): Promise<FxAgent>
34
+ }
package/src/reducer.ts CHANGED
@@ -62,7 +62,7 @@ export function foldEvent<D extends EventDefs, S>(
62
62
  * the browser cache) are keyed by it, which makes it the invalidation
63
63
  * knob too: changed the fold's logic? Change the name. Snapshots under
64
64
  * the old name are ignored and the next read refolds from raw events.
65
- * See docs/concepts/04-state.mdx.
65
+ * See docs/contract/02-state.mdx.
66
66
  */
67
67
  export type Reducer<
68
68
  D extends EventDefs = EventDefs,
@@ -912,7 +912,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
912
912
  }
913
913
  if (result.settled) return response.ok()
914
914
 
915
- if (result.outcome === 'busy') {
915
+ if (result.outcome === 'busy' || result.outcome === 'stalled') {
916
916
  if (result.schedulerArm) await result.schedulerArm
917
917
  else {
918
918
  await sendTask({
@@ -926,10 +926,10 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
926
926
  return response.ok()
927
927
  }
928
928
 
929
- // QStash's per-message retry default can be lower than A2's failure
930
- // budget. Hand stalled work to a fresh durable message so recovery
931
- // continues until the store reports success or dead-letter. If this
932
- // publish fails, the throw keeps the current delivery retryable.
929
+ // A drain whose outcome is unknown hands its work to a fresh durable
930
+ // message: QStash's per-message retry default can be lower than A2's
931
+ // failure budget. If this publish fails, the throw keeps the current
932
+ // delivery retryable.
933
933
  await sendTask({
934
934
  version: 1,
935
935
  kind: 'drain',
@@ -14,8 +14,7 @@
14
14
  * the configured deployment scope for sending and receiving.
15
15
  * - `handler(...servers)` is the delivery route: look up the server for
16
16
  * the contract named in the message and drain it. Settled drains ack;
17
- * contention arms a later watchdog before acking; handler failure keeps
18
- * the current message and redelivers with backoff.
17
+ * contention and handler failure arm a later watchdog before acking.
19
18
  *
20
19
  * `@vercel/queue` is an optional peer dependency, imported lazily. The
21
20
  * `transport` option is the injection seam — tests run a real little
@@ -44,12 +43,12 @@ import {
44
43
  } from './server.ts'
45
44
 
46
45
  /**
47
- * The "not settled yet" signal for a stalled handler — an expected state,
48
- * not a transport failure. The default transport classifies it in
49
- * `@vercel/queue`'s `retry` option and reschedules the delivery quietly
46
+ * The "not settled yet" signal for a drain whose outcome is unknown — an
47
+ * expected state, not a transport failure. The default transport classifies
48
+ * it in `@vercel/queue`'s `retry` option and reschedules the delivery quietly
50
49
  * (`{ afterSeconds }` → visibility change → 200 response, no error log)
51
- * instead of letting it surface as a thrown 500. Busy deliveries use a
52
- * fresh watchdog instead. Custom transports should treat this class the
50
+ * instead of letting it surface as a thrown 500. Busy and stalled deliveries
51
+ * use a fresh watchdog instead. Custom transports should treat this class the
53
52
  * same way when their protocol has a polite reschedule.
54
53
  */
55
54
  export class DrainUnsettled extends Error {}
@@ -354,11 +353,12 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
354
353
  }
355
354
  if (result.settled) return
356
355
 
357
- if (result.outcome === 'busy') {
358
- // The live owner normally arms its own claim windows.
359
- // Ensure a later slot here as well before relinquishing this
360
- // durable delivery. If the queue is down, the throw leaves the
361
- // current message unacked for visibility-timeout redelivery.
356
+ if (result.outcome === 'busy' || result.outcome === 'stalled') {
357
+ // The live owner arms its own claim windows, and the store holds
358
+ // a failed event until its retry time. Ensure a later slot here
359
+ // as well before relinquishing this durable delivery. If the queue is down, the
360
+ // throw leaves the current message unacked for visibility-timeout
361
+ // redelivery.
362
362
  if (result.schedulerArm) await result.schedulerArm
363
363
  else {
364
364
  await sendTask({
@@ -372,9 +372,8 @@ export function vercelQueues(options: VercelQueuesOptions = {}): A2Scheduler {
372
372
  return
373
373
  }
374
374
 
375
- // Handler failure keeps the current durable message and uses the
376
- // transport retry path. Dead-lettered sessions count as settled
377
- // above, so retries stop there.
375
+ // A drain whose outcome is unknown keeps the current durable message
376
+ // and uses the transport retry path.
378
377
  throw new DrainUnsettled(
379
378
  `a2 scheduler: session '${task.sessionId}' of '${task.contract}' not settled yet (${result.outcome}) — rescheduled for redelivery`,
380
379
  )
package/src/server.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  DRAIN_TIMINGS,
26
26
  POLL_TIMINGS,
27
27
  MAX_DATE_MS,
28
+ failedPreviousAttempt,
28
29
  RESERVED_PARTICIPANT_IDS,
29
30
  STATE_CACHE,
30
31
  markSchedulerSendFailure,
@@ -55,6 +56,7 @@ import type {
55
56
  StoreClaimAvailableResult,
56
57
  StoreClearResult,
57
58
  StoreReclaimResult,
59
+ StoreRedriveResult,
58
60
  StoreSnapshotWrite,
59
61
  StoreStateRead,
60
62
  PresenceRow,
@@ -188,6 +190,48 @@ export type SessionClearOptions = {
188
190
 
189
191
  export type SessionClearResult = { cleared: boolean; index: number }
190
192
 
193
+ export type SessionRedriveOptions = {
194
+ /** `retry` runs the handler again with a fresh failure budget; `skip` settles the event without running it. */
195
+ action: 'retry' | 'skip'
196
+ }
197
+
198
+ export type SessionRedriveResult = {
199
+ /** Whether the event was dead-lettered and is now resolved. */
200
+ redriven: boolean
201
+ }
202
+
203
+ /** A dead-lettered event, with what an operator needs to resolve it. */
204
+ export type DeadLetter<D extends EventDefs> = ContractEvent<D> & {
205
+ lane: string | null
206
+ /** Durable dispatch claims, including claims abandoned by hard kills. */
207
+ attemptCount: number
208
+ /**
209
+ * Failed attempts since the event was appended or last retried: caught
210
+ * handler failures, and attempts that ended before they finished.
211
+ */
212
+ failureCount: number
213
+ /**
214
+ * The last error a handler threw, stringified; null when every failed
215
+ * attempt ended before it finished.
216
+ */
217
+ lastError: string | null
218
+ failedAt: Date
219
+ }
220
+
221
+ /** Dead-letter resolution; root sessions only. */
222
+ export type SessionDeadLetters<D extends EventDefs> = {
223
+ /** The session's dead-lettered events, oldest first. */
224
+ deadLetters(): Promise<DeadLetter<D>[]>
225
+ /**
226
+ * Atomically resolve one dead-lettered event, then wake the session like
227
+ * an append. Any other event is left alone and reports `redriven: false`.
228
+ */
229
+ redrive(
230
+ index: number,
231
+ options: SessionRedriveOptions,
232
+ ): Promise<SessionRedriveResult>
233
+ }
234
+
191
235
  /**
192
236
  * The presence members of a session — intersected in via
193
237
  * `WithPresence`, so they exist exactly when the contract declares
@@ -293,7 +337,9 @@ export type A2Server<
293
337
  (request: Request): Promise<Response>
294
338
  (request: Request, options: ServerFetchOptions<D, P>): Promise<Response>
295
339
  }
296
- session(id: string): Session<D, SessionAppend<D>, P, PublicState<R>>
340
+ session(
341
+ id: string,
342
+ ): Session<D, SessionAppend<D>, P, PublicState<R>> & SessionDeadLetters<D>
297
343
  /**
298
344
  * Process every currently eligible event. `settled` means nothing
299
345
  * actionable remains, including work blocked behind a dead letter.
@@ -459,6 +505,13 @@ export type ServerOptions<
459
505
 
460
506
  const MAX_FAILURES = 10
461
507
 
508
+ /** How long an event waits after its `failureCount`th failed attempt. */
509
+ const failureRetryDelayMs = (failureCount: number): number =>
510
+ Math.min(
511
+ DRAIN_TIMINGS.failureRetryBaseMs * 2 ** Math.max(failureCount - 1, 0),
512
+ DRAIN_TIMINGS.failureRetryMaxMs,
513
+ )
514
+
462
515
  /** Abort a lost attempt's handler and release its drain slot. */
463
516
  function endSuperseded(
464
517
  entry: {
@@ -487,6 +540,7 @@ type ClaimWindow = {
487
540
  expiresAtMs: number
488
541
  watchdogAtMs: number
489
542
  deadlineCapped: boolean
543
+ pastDeadline: boolean
490
544
  }
491
545
 
492
546
  type DrainSignal = {
@@ -561,9 +615,11 @@ function pruneAbortHub(hub: AbortHub): void {
561
615
  }
562
616
 
563
617
  /**
564
- * Deadlines tighten the claim/watchdog window; they never decide whether a
565
- * handler starts. A platform timeout therefore follows the same path as any
566
- * other process death: the in-flight event remains pending and is retried.
618
+ * Deadlines tighten the claim/watchdog window, and a passed deadline ends
619
+ * claiming: a claim then could not run, only lapse and be claimed again until
620
+ * the platform ends the process. A platform timeout otherwise follows the
621
+ * same path as any other process death: the in-flight event remains pending
622
+ * and a fresh invocation retries it.
567
623
  */
568
624
  function nextClaimWindow(): ClaimWindow {
569
625
  const nowMs = Date.now()
@@ -579,6 +635,7 @@ function nextClaimWindow(): ClaimWindow {
579
635
  expiresAtMs,
580
636
  watchdogAtMs: expiresAtMs + DRAIN_TIMINGS.watchdogGraceMs,
581
637
  deadlineCapped,
638
+ pastDeadline: deadlineMs !== null && deadlineTtlMs <= 0,
582
639
  }
583
640
  }
584
641
 
@@ -603,6 +660,27 @@ const devDefaultStore = retryableLazy(() =>
603
660
  import('./store-sqlite.ts').then((m) => m.sqlite()),
604
661
  )
605
662
 
663
+ /** Waits for a scheduler arm for at most `schedulerArmTimeoutMs`. */
664
+ async function joinSchedulerArm(arm: Promise<void>): Promise<void> {
665
+ let timeout: ReturnType<typeof setTimeout> | null = null
666
+ try {
667
+ await Promise.race([
668
+ arm,
669
+ new Promise<never>((_, reject) => {
670
+ timeout = setTimeout(() => {
671
+ reject(
672
+ new Error(
673
+ `a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`,
674
+ ),
675
+ )
676
+ }, DRAIN_TIMINGS.schedulerArmTimeoutMs)
677
+ }),
678
+ ])
679
+ } finally {
680
+ if (timeout) clearTimeout(timeout)
681
+ }
682
+ }
683
+
606
684
  function environment(): 'development' | 'test' | 'production' {
607
685
  const env =
608
686
  typeof process === 'undefined' ? undefined : process.env?.['NODE_ENV']
@@ -1081,26 +1159,12 @@ export function createServer<
1081
1159
  await dispatchArm
1082
1160
  }
1083
1161
  if (initialArm) {
1084
- let timeout: ReturnType<typeof setTimeout> | null = null
1085
1162
  try {
1086
- await Promise.race([
1087
- initialArm,
1088
- new Promise<never>((_, reject) => {
1089
- timeout = setTimeout(() => {
1090
- reject(
1091
- new Error(
1092
- `a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`,
1093
- ),
1094
- )
1095
- }, DRAIN_TIMINGS.schedulerArmTimeoutMs)
1096
- }),
1097
- ])
1163
+ await joinSchedulerArm(initialArm)
1098
1164
  } catch (err) {
1099
1165
  span.setAttribute('a2.append.armed', false)
1100
1166
  span.recordError(err)
1101
1167
  if (requireDurableInitialArm) throw err
1102
- } finally {
1103
- if (timeout) clearTimeout(timeout)
1104
1168
  }
1105
1169
  }
1106
1170
  return appended
@@ -2483,6 +2547,102 @@ export function createServer<
2483
2547
  return { cleared: result.cleared, index: result.index }
2484
2548
  }
2485
2549
 
2550
+ // ── dead letters ─────────────────────────────────────────────────
2551
+
2552
+ const redrivingStore = async (
2553
+ method: 'deadLetters' | 'redrive',
2554
+ ): Promise<A2Store & Required<Pick<A2Store, 'deadLetters' | 'redrive'>>> => {
2555
+ const store = await resolveStore()
2556
+ if (!store.deadLetters || !store.redrive) {
2557
+ throw new TypeError(
2558
+ `session.${method} requires a store that supports redriving dead letters`,
2559
+ )
2560
+ }
2561
+ return store as A2Store & Required<Pick<A2Store, 'deadLetters' | 'redrive'>>
2562
+ }
2563
+
2564
+ const listDeadLetters = async (
2565
+ sessionId: string,
2566
+ ): Promise<DeadLetter<D>[]> => {
2567
+ const store = await redrivingStore('deadLetters')
2568
+ let rows: StoredEvent[]
2569
+ try {
2570
+ rows = await store.deadLetters(nsId(sessionId))
2571
+ } catch (err) {
2572
+ throw asStoreUnavailable(err)
2573
+ }
2574
+ return rows.flatMap((row) =>
2575
+ row.failedAt === null
2576
+ ? []
2577
+ : [
2578
+ {
2579
+ ...toPublic(row),
2580
+ lane: row.lane,
2581
+ attemptCount: row.attemptCount,
2582
+ failureCount: row.failureCount,
2583
+ lastError: row.lastError,
2584
+ failedAt: row.failedAt,
2585
+ },
2586
+ ],
2587
+ )
2588
+ }
2589
+
2590
+ const redriveEvent = async (
2591
+ sessionId: string,
2592
+ index: number,
2593
+ redriveOptions: SessionRedriveOptions,
2594
+ ): Promise<SessionRedriveResult> => {
2595
+ assertRedriveIndex(index)
2596
+ const action = redriveOptions?.action
2597
+ if (action !== 'retry' && action !== 'skip') {
2598
+ throw new TypeError("redrive requires action 'retry' or 'skip'")
2599
+ }
2600
+ return telemetry.span(
2601
+ 'a2.redrive',
2602
+ {
2603
+ 'a2.contract': name,
2604
+ 'a2.session_id': sessionId,
2605
+ 'a2.redrive.index': index,
2606
+ 'a2.redrive.action': action,
2607
+ },
2608
+ async (span) => {
2609
+ const store = await redrivingStore('redrive')
2610
+ let result: StoreRedriveResult
2611
+ try {
2612
+ result = await store.redrive({
2613
+ sessionId: nsId(sessionId),
2614
+ index,
2615
+ action,
2616
+ })
2617
+ } catch (err) {
2618
+ throw asStoreUnavailable(err)
2619
+ }
2620
+ span.setAttribute('a2.redrive.redriven', result.redriven)
2621
+ // Healing like a top-level append, even when nothing was redriven, so
2622
+ // repeating a redrive whose wakeup was lost resumes the session.
2623
+ if (result.hasPending) {
2624
+ const watchdogAt = scheduler
2625
+ ? schedulerSlot(nextClaimWindow().watchdogAtMs)
2626
+ : undefined
2627
+ const arm =
2628
+ watchdogAt === undefined
2629
+ ? null
2630
+ : startSchedulerArm(sessionId, watchdogAt)
2631
+ scheduleDrain(sessionId, watchdogAt)
2632
+ if (arm) {
2633
+ try {
2634
+ await joinSchedulerArm(arm)
2635
+ } catch (err) {
2636
+ span.setAttribute('a2.redrive.armed', false)
2637
+ span.recordError(err)
2638
+ }
2639
+ }
2640
+ }
2641
+ return { redriven: result.redriven }
2642
+ },
2643
+ )
2644
+ }
2645
+
2486
2646
  const makeSession = <Append>(
2487
2647
  id: string,
2488
2648
  append: Append,
@@ -2584,7 +2744,7 @@ export function createServer<
2584
2744
  generatedIds,
2585
2745
  )
2586
2746
  }
2587
- return {
2747
+ const ctx: HandlerContext<D> = {
2588
2748
  event: toPublic(trigger),
2589
2749
  attempt,
2590
2750
  // The handler-scoped `setPresence` stamps `seen` with the
@@ -2597,6 +2757,9 @@ export function createServer<
2597
2757
  }),
2598
2758
  signal,
2599
2759
  }
2760
+ if (attempt > 1 && trigger.lastFailedAttempt === attempt - 1)
2761
+ failedPreviousAttempt.add(ctx)
2762
+ return ctx
2600
2763
  }
2601
2764
 
2602
2765
  const abortHubs = new Map<string, AbortHub>()
@@ -2922,6 +3085,25 @@ export function createServer<
2922
3085
  return tracked
2923
3086
  }
2924
3087
 
3088
+ /** Records an event the store dead-lettered on takeover; it never runs. */
3089
+ const reportExhausted = (event: StoredEvent): Promise<void> =>
3090
+ telemetry.span(
3091
+ 'a2.event',
3092
+ {
3093
+ 'a2.contract': name,
3094
+ 'a2.session_id': sessionId,
3095
+ 'a2.event.type': event.type,
3096
+ 'a2.event.index': event.index,
3097
+ 'a2.event.id': event.id,
3098
+ 'a2.event.attempt': event.attemptCount,
3099
+ 'a2.event.handled': handlers.has(event.type),
3100
+ ...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
3101
+ },
3102
+ async (eventSpan) => {
3103
+ eventSpan.setAttribute('a2.event.outcome', 'dead_lettered')
3104
+ },
3105
+ )
3106
+
2925
3107
  const runEvent = async (
2926
3108
  event: StoredEvent,
2927
3109
  supersedeSignal: AbortSignal,
@@ -2981,9 +3163,8 @@ export function createServer<
2981
3163
  if (supersedeSignal.aborted && err === supersedeSignal.reason) {
2982
3164
  // The handler surrendered to its own abort — identity, not
2983
3165
  // code, so it cannot arrive from another context. A lapse
2984
- // is not a handler failure: no completion, no failure
2985
- // budget spent; the claim simply expires and recovery
2986
- // retries.
3166
+ // is not a handler failure: no completion; the claim simply
3167
+ // expires, and the claim that takes it over counts it.
2987
3168
  eventSpan.setAttribute('a2.event.outcome', 'surrendered')
2988
3169
  return {
2989
3170
  status: 'surrendered',
@@ -3016,6 +3197,7 @@ export function createServer<
3016
3197
  // budget before dead-lettering.
3017
3198
  maxFailures:
3018
3199
  err instanceof NonRetriableError ? 1 : MAX_FAILURES,
3200
+ retryAfterMs: failureRetryDelayMs(event.failureCount + 1),
3019
3201
  })
3020
3202
  commitSpan.setAttribute('a2.commit.outcome', result.outcome)
3021
3203
  return result
@@ -3147,6 +3329,10 @@ export function createServer<
3147
3329
  let claim: StoreClaimAvailableResult | undefined
3148
3330
  if (shouldClaim) {
3149
3331
  const window = nextClaimWindow()
3332
+ if (window.pastDeadline) {
3333
+ outcome = 'busy'
3334
+ break
3335
+ }
3150
3336
  claim = await telemetry.span(
3151
3337
  'a2.claim',
3152
3338
  { 'a2.contract': name, 'a2.session_id': sessionId },
@@ -3155,6 +3341,7 @@ export function createServer<
3155
3341
  sessionId: ns,
3156
3342
  holder,
3157
3343
  ttlMs: window.ttlMs,
3344
+ maxFailures: MAX_FAILURES,
3158
3345
  ...(window.deadlineCapped
3159
3346
  ? { expiresAtMs: window.expiresAtMs }
3160
3347
  : {}),
@@ -3171,11 +3358,16 @@ export function createServer<
3171
3358
  },
3172
3359
  )
3173
3360
  if (claim.outcome === 'claimed') {
3174
- requestWatchdog(window.watchdogAtMs)
3361
+ // A takeover that spent the last failed attempt comes back
3362
+ // dead-lettered instead of claimed.
3363
+ const runnable = claim.events.filter(
3364
+ (event) => event.failedAt === null,
3365
+ )
3366
+ for (const event of claim.events)
3367
+ if (event.failedAt !== null) await reportExhausted(event)
3368
+ if (runnable.length > 0) requestWatchdog(window.watchdogAtMs)
3175
3369
  if (window.deadlineCapped) stopRenewing = true
3176
- for (const event of claim.events) {
3177
- start(event, window.expiresAtMs)
3178
- }
3370
+ for (const event of runnable) start(event, window.expiresAtMs)
3179
3371
  shouldClaim = false
3180
3372
  }
3181
3373
  }
@@ -3276,7 +3468,9 @@ export function createServer<
3276
3468
  )
3277
3469
  }
3278
3470
 
3279
- const session = (id: string): Session<D, SessionAppend<D>, P> => {
3471
+ const session = (
3472
+ id: string,
3473
+ ): Session<D, SessionAppend<D>, P> & SessionDeadLetters<D> => {
3280
3474
  assertSessionId(id)
3281
3475
  const appendInline = async (...events: AppendInput<D>[]) =>
3282
3476
  appendExternal(id, 'inline', events)
@@ -3285,7 +3479,11 @@ export function createServer<
3285
3479
  const append = Object.assign(appendInline, {
3286
3480
  dispatch: appendDispatch,
3287
3481
  }) as SessionAppend<D>
3288
- return makeSession(id, append, null)
3482
+ return Object.assign(makeSession(id, append, null), {
3483
+ deadLetters: () => listDeadLetters(id),
3484
+ redrive: (index: number, redriveOptions: SessionRedriveOptions) =>
3485
+ redriveEvent(id, index, redriveOptions),
3486
+ })
3289
3487
  }
3290
3488
 
3291
3489
  const historyPage = async (
@@ -3393,6 +3591,12 @@ function assertStateIndex(value: number): void {
3393
3591
  }
3394
3592
  }
3395
3593
 
3594
+ function assertRedriveIndex(value: number): void {
3595
+ if (!Number.isSafeInteger(value) || value < 1) {
3596
+ throw new TypeError('redrive requires a positive safe-integer index')
3597
+ }
3598
+ }
3599
+
3396
3600
  function assertClearIndex(value: number): void {
3397
3601
  if (!Number.isSafeInteger(value) || value < 0) {
3398
3602
  throw new TypeError('ifLastIndex must be a non-negative safe integer')