experimental-a2 0.17.0 → 0.18.0

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 (211) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-Bjce9_5U.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-Bjce9_5U.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-UrWfKeuv.js → ai-BpOGm-zB.js} +2 -2
  11. package/dist/{ai-UrWfKeuv.js.map → ai-BpOGm-zB.js.map} +1 -1
  12. package/dist/{ai-vpGtcaCB.d.ts → ai-DrUaMyRt.d.ts} +3 -3
  13. package/dist/{ai-vpGtcaCB.d.ts.map → ai-DrUaMyRt.d.ts.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 +634 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-BuLh_EYi.js +273 -0
  24. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  25. package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
  26. package/dist/ai-harness-DvsPeJeB.d.ts.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 +904 -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 +106 -37
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai.d.ts +1 -1
  41. package/dist/ai.js +1 -1
  42. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  43. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  44. package/dist/{client-COaiaDub.js → client-BReKqwZC.js} +3 -3
  45. package/dist/{client-COaiaDub.js.map → client-BReKqwZC.js.map} +1 -1
  46. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  47. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  48. package/dist/client.d.ts +3 -3
  49. package/dist/client.js +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  52. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  53. package/dist/{internal-CbVvEwad.js → internal-B2T3U4Rq.js} +8 -2
  54. package/dist/internal-B2T3U4Rq.js.map +1 -0
  55. package/dist/otel.d.ts +1 -1
  56. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  57. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/reducer-BAXc8JyF.js.map +1 -1
  61. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  62. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  63. package/dist/scheduler-qstash.d.ts +2 -2
  64. package/dist/scheduler-qstash.js +2 -2
  65. package/dist/scheduler-vercel.d.ts +2 -2
  66. package/dist/scheduler-vercel.js +2 -2
  67. package/dist/{server-Rj64fNvX.js → server-Dw_k_7kO.js} +93 -60
  68. package/dist/server-Dw_k_7kO.js.map +1 -0
  69. package/dist/{server-CGM8WzRG.d.ts → server-En7cbi3v.d.ts} +36 -7
  70. package/dist/server-En7cbi3v.d.ts.map +1 -0
  71. package/dist/server.d.ts +4 -4
  72. package/dist/server.js +1 -1
  73. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-Bo-XTFEy.d.ts} +2 -2
  74. package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
  75. package/dist/{store-Gpkq7WBo.d.ts → store-Djdv0bnZ.d.ts} +31 -2
  76. package/dist/store-Djdv0bnZ.d.ts.map +1 -0
  77. package/dist/store-N8PXxDAS.js.map +1 -1
  78. package/dist/store-memory.d.ts +1 -1
  79. package/dist/store-memory.js +32 -6
  80. package/dist/store-memory.js.map +1 -1
  81. package/dist/{store-polling-4_y8lETm.js → store-polling-B5FcwJfo.js} +2 -2
  82. package/dist/{store-polling-4_y8lETm.js.map → store-polling-B5FcwJfo.js.map} +1 -1
  83. package/dist/store-postgres.d.ts +1 -1
  84. package/dist/store-postgres.js +54 -3
  85. package/dist/store-postgres.js.map +1 -1
  86. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-BAwS8RWn.js} +3 -3
  87. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
  88. package/dist/store-redis-http.d.ts +1 -1
  89. package/dist/store-redis-http.js +3 -3
  90. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-BRITti_o.js} +187 -68
  91. package/dist/store-redis-notify-BRITti_o.js.map +1 -0
  92. package/dist/store-redis.d.ts +1 -1
  93. package/dist/store-redis.js +1 -1
  94. package/dist/store-sqlite.d.ts +1 -1
  95. package/dist/store-sqlite.js +32 -2
  96. package/dist/store-sqlite.js.map +1 -1
  97. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  98. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  99. package/dist/{wire-DjfHOvdW.js → wire-rSUglYo8.js} +2 -2
  100. package/dist/{wire-DjfHOvdW.js.map → wire-rSUglYo8.js.map} +1 -1
  101. package/docs/01-quickstart.mdx +49 -83
  102. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  103. package/docs/actors/01-introduction.mdx +48 -86
  104. package/docs/actors/02-concurrency.mdx +43 -70
  105. package/docs/actors/03-timers.mdx +28 -42
  106. package/docs/actors/04-routes.mdx +110 -150
  107. package/docs/actors/meta.ts +1 -1
  108. package/docs/agents/01-introduction.mdx +257 -0
  109. package/docs/agents/02-tools.mdx +247 -0
  110. package/docs/agents/03-conversations.mdx +538 -0
  111. package/docs/agents/04-harnesses.mdx +136 -0
  112. package/docs/agents/meta.ts +1 -0
  113. package/docs/client/01-react.mdx +294 -0
  114. package/docs/client/02-transports.mdx +86 -0
  115. package/docs/client/03-presence.mdx +224 -0
  116. package/docs/client/meta.ts +1 -0
  117. package/docs/contract/01-events.mdx +57 -0
  118. package/docs/contract/02-state.mdx +142 -0
  119. package/docs/contract/03-sessions.mdx +175 -0
  120. package/docs/index.mdx +108 -144
  121. package/docs/reference/01-core.mdx +139 -0
  122. package/docs/reference/02-server.mdx +855 -0
  123. package/docs/reference/03-client.mdx +301 -0
  124. package/docs/reference/04-ai.mdx +722 -0
  125. package/docs/reference/05-adapters.mdx +351 -0
  126. package/docs/reference/06-errors.mdx +138 -0
  127. package/docs/reference/meta.ts +1 -1
  128. package/docs/server/01-handlers.mdx +200 -0
  129. package/docs/server/02-timers.mdx +130 -0
  130. package/docs/server/03-cancellation.mdx +141 -0
  131. package/docs/server/04-routes.mdx +243 -0
  132. package/docs/server/05-production.mdx +322 -0
  133. package/docs/server/06-guarantees.mdx +123 -0
  134. package/examples/playground/DEPLOY.md +1 -1
  135. package/examples/playground/app/a0/[appId]/page.tsx +11 -6
  136. package/examples/playground/app/a0/app-list.tsx +9 -2
  137. package/examples/playground/app/a0/app-view.tsx +9 -1
  138. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  139. package/examples/playground/app/a0/apps.test.ts +12 -0
  140. package/examples/playground/app/a0/apps.ts +22 -7
  141. package/examples/playground/app/a0/chat.tsx +47 -1
  142. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  143. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  144. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  145. package/examples/playground/app/a0/model.test.ts +18 -1
  146. package/examples/playground/app/a0/model.ts +47 -10
  147. package/examples/playground/app/a0/no-photon.cjs +4 -0
  148. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  149. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  150. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  153. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  154. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  155. package/examples/playground/app/a0/server.ts +100 -45
  156. package/examples/playground/app/a0/session.ts +17 -5
  157. package/examples/playground/app/a0/shell.tsx +11 -3
  158. package/examples/playground/app/agent/ingress.test.ts +6 -3
  159. package/examples/playground/app/agent/server.ts +9 -6
  160. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  161. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  162. package/examples/playground/app/chat/server.test.ts +30 -26
  163. package/examples/playground/app/chat/server.ts +46 -46
  164. package/examples/playground/next.config.ts +17 -5
  165. package/examples/playground/package.json +4 -1
  166. package/examples/playground/vercel.json +18 -0
  167. package/package.json +27 -3
  168. package/src/ai-agent.ts +261 -87
  169. package/src/ai-fx.browser.ts +5 -0
  170. package/src/ai-fx.ts +833 -0
  171. package/src/ai-harness.ts +518 -0
  172. package/src/ai-model-metadata.ts +11 -6
  173. package/src/ai-pi.browser.ts +5 -0
  174. package/src/ai-pi.ts +1263 -0
  175. package/src/ai-sdk-step.ts +2 -2
  176. package/src/ai-server.ts +3 -1
  177. package/src/internal.ts +7 -0
  178. package/src/libfx.d.ts +34 -0
  179. package/src/reducer.ts +1 -1
  180. package/src/server.ts +177 -19
  181. package/src/store-memory.ts +42 -7
  182. package/src/store-postgres.ts +80 -0
  183. package/src/store-redis-core.ts +210 -79
  184. package/src/store-redis-notify.ts +2 -0
  185. package/src/store-sqlite.ts +60 -0
  186. package/src/store.ts +33 -0
  187. package/src/telemetry.ts +1 -0
  188. package/dist/internal-CbVvEwad.js.map +0 -1
  189. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  190. package/dist/server-Rj64fNvX.js.map +0 -1
  191. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  192. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  193. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  194. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  195. package/docs/concepts/01-contracts.mdx +0 -237
  196. package/docs/concepts/02-handlers.mdx +0 -284
  197. package/docs/concepts/03-durability.mdx +0 -314
  198. package/docs/concepts/04-state.mdx +0 -209
  199. package/docs/guides/01-timers.mdx +0 -193
  200. package/docs/guides/02-cancellation.mdx +0 -161
  201. package/docs/guides/03-react.mdx +0 -324
  202. package/docs/guides/04-local-first.mdx +0 -47
  203. package/docs/guides/05-production.mdx +0 -534
  204. package/docs/guides/06-ai-agents.mdx +0 -1408
  205. package/docs/guides/08-application-data.mdx +0 -113
  206. package/docs/guides/09-presence.mdx +0 -262
  207. package/docs/guides/10-transports.mdx +0 -221
  208. package/docs/reference/01-api.mdx +0 -2358
  209. package/docs/reference/02-errors.mdx +0 -150
  210. /package/docs/{concepts → contract}/meta.ts +0 -0
  211. /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'
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
 
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,
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,42 @@ 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
+ /** Caught handler failures since the event was appended or last retried. */
209
+ failureCount: number
210
+ /** The last handler failure, stringified. */
211
+ lastError: string | null
212
+ failedAt: Date
213
+ }
214
+
215
+ /** Dead-letter resolution; root sessions only. */
216
+ export type SessionDeadLetters<D extends EventDefs> = {
217
+ /** The session's dead-lettered events, oldest first. */
218
+ deadLetters(): Promise<DeadLetter<D>[]>
219
+ /**
220
+ * Atomically resolve one dead-lettered event, then wake the session like
221
+ * an append. Any other event is left alone and reports `redriven: false`.
222
+ */
223
+ redrive(
224
+ index: number,
225
+ options: SessionRedriveOptions,
226
+ ): Promise<SessionRedriveResult>
227
+ }
228
+
191
229
  /**
192
230
  * The presence members of a session — intersected in via
193
231
  * `WithPresence`, so they exist exactly when the contract declares
@@ -293,7 +331,9 @@ export type A2Server<
293
331
  (request: Request): Promise<Response>
294
332
  (request: Request, options: ServerFetchOptions<D, P>): Promise<Response>
295
333
  }
296
- session(id: string): Session<D, SessionAppend<D>, P, PublicState<R>>
334
+ session(
335
+ id: string,
336
+ ): Session<D, SessionAppend<D>, P, PublicState<R>> & SessionDeadLetters<D>
297
337
  /**
298
338
  * Process every currently eligible event. `settled` means nothing
299
339
  * actionable remains, including work blocked behind a dead letter.
@@ -603,6 +643,27 @@ const devDefaultStore = retryableLazy(() =>
603
643
  import('./store-sqlite.ts').then((m) => m.sqlite()),
604
644
  )
605
645
 
646
+ /** Waits for a scheduler arm for at most `schedulerArmTimeoutMs`. */
647
+ async function joinSchedulerArm(arm: Promise<void>): Promise<void> {
648
+ let timeout: ReturnType<typeof setTimeout> | null = null
649
+ try {
650
+ await Promise.race([
651
+ arm,
652
+ new Promise<never>((_, reject) => {
653
+ timeout = setTimeout(() => {
654
+ reject(
655
+ new Error(
656
+ `a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`,
657
+ ),
658
+ )
659
+ }, DRAIN_TIMINGS.schedulerArmTimeoutMs)
660
+ }),
661
+ ])
662
+ } finally {
663
+ if (timeout) clearTimeout(timeout)
664
+ }
665
+ }
666
+
606
667
  function environment(): 'development' | 'test' | 'production' {
607
668
  const env =
608
669
  typeof process === 'undefined' ? undefined : process.env?.['NODE_ENV']
@@ -1081,26 +1142,12 @@ export function createServer<
1081
1142
  await dispatchArm
1082
1143
  }
1083
1144
  if (initialArm) {
1084
- let timeout: ReturnType<typeof setTimeout> | null = null
1085
1145
  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
- ])
1146
+ await joinSchedulerArm(initialArm)
1098
1147
  } catch (err) {
1099
1148
  span.setAttribute('a2.append.armed', false)
1100
1149
  span.recordError(err)
1101
1150
  if (requireDurableInitialArm) throw err
1102
- } finally {
1103
- if (timeout) clearTimeout(timeout)
1104
1151
  }
1105
1152
  }
1106
1153
  return appended
@@ -2483,6 +2530,102 @@ export function createServer<
2483
2530
  return { cleared: result.cleared, index: result.index }
2484
2531
  }
2485
2532
 
2533
+ // ── dead letters ─────────────────────────────────────────────────
2534
+
2535
+ const redrivingStore = async (
2536
+ method: 'deadLetters' | 'redrive',
2537
+ ): Promise<A2Store & Required<Pick<A2Store, 'deadLetters' | 'redrive'>>> => {
2538
+ const store = await resolveStore()
2539
+ if (!store.deadLetters || !store.redrive) {
2540
+ throw new TypeError(
2541
+ `session.${method} requires a store that supports redriving dead letters`,
2542
+ )
2543
+ }
2544
+ return store as A2Store & Required<Pick<A2Store, 'deadLetters' | 'redrive'>>
2545
+ }
2546
+
2547
+ const listDeadLetters = async (
2548
+ sessionId: string,
2549
+ ): Promise<DeadLetter<D>[]> => {
2550
+ const store = await redrivingStore('deadLetters')
2551
+ let rows: StoredEvent[]
2552
+ try {
2553
+ rows = await store.deadLetters(nsId(sessionId))
2554
+ } catch (err) {
2555
+ throw asStoreUnavailable(err)
2556
+ }
2557
+ return rows.flatMap((row) =>
2558
+ row.failedAt === null
2559
+ ? []
2560
+ : [
2561
+ {
2562
+ ...toPublic(row),
2563
+ lane: row.lane,
2564
+ attemptCount: row.attemptCount,
2565
+ failureCount: row.failureCount,
2566
+ lastError: row.lastError,
2567
+ failedAt: row.failedAt,
2568
+ },
2569
+ ],
2570
+ )
2571
+ }
2572
+
2573
+ const redriveEvent = async (
2574
+ sessionId: string,
2575
+ index: number,
2576
+ redriveOptions: SessionRedriveOptions,
2577
+ ): Promise<SessionRedriveResult> => {
2578
+ assertRedriveIndex(index)
2579
+ const action = redriveOptions?.action
2580
+ if (action !== 'retry' && action !== 'skip') {
2581
+ throw new TypeError("redrive requires action 'retry' or 'skip'")
2582
+ }
2583
+ return telemetry.span(
2584
+ 'a2.redrive',
2585
+ {
2586
+ 'a2.contract': name,
2587
+ 'a2.session_id': sessionId,
2588
+ 'a2.redrive.index': index,
2589
+ 'a2.redrive.action': action,
2590
+ },
2591
+ async (span) => {
2592
+ const store = await redrivingStore('redrive')
2593
+ let result: StoreRedriveResult
2594
+ try {
2595
+ result = await store.redrive({
2596
+ sessionId: nsId(sessionId),
2597
+ index,
2598
+ action,
2599
+ })
2600
+ } catch (err) {
2601
+ throw asStoreUnavailable(err)
2602
+ }
2603
+ span.setAttribute('a2.redrive.redriven', result.redriven)
2604
+ // Healing like a top-level append, even when nothing was redriven, so
2605
+ // repeating a redrive whose wakeup was lost resumes the session.
2606
+ if (result.hasPending) {
2607
+ const watchdogAt = scheduler
2608
+ ? schedulerSlot(nextClaimWindow().watchdogAtMs)
2609
+ : undefined
2610
+ const arm =
2611
+ watchdogAt === undefined
2612
+ ? null
2613
+ : startSchedulerArm(sessionId, watchdogAt)
2614
+ scheduleDrain(sessionId, watchdogAt)
2615
+ if (arm) {
2616
+ try {
2617
+ await joinSchedulerArm(arm)
2618
+ } catch (err) {
2619
+ span.setAttribute('a2.redrive.armed', false)
2620
+ span.recordError(err)
2621
+ }
2622
+ }
2623
+ }
2624
+ return { redriven: result.redriven }
2625
+ },
2626
+ )
2627
+ }
2628
+
2486
2629
  const makeSession = <Append>(
2487
2630
  id: string,
2488
2631
  append: Append,
@@ -2584,7 +2727,7 @@ export function createServer<
2584
2727
  generatedIds,
2585
2728
  )
2586
2729
  }
2587
- return {
2730
+ const ctx: HandlerContext<D> = {
2588
2731
  event: toPublic(trigger),
2589
2732
  attempt,
2590
2733
  // The handler-scoped `setPresence` stamps `seen` with the
@@ -2597,6 +2740,9 @@ export function createServer<
2597
2740
  }),
2598
2741
  signal,
2599
2742
  }
2743
+ if (attempt > 1 && trigger.lastFailedAttempt === attempt - 1)
2744
+ failedPreviousAttempt.add(ctx)
2745
+ return ctx
2600
2746
  }
2601
2747
 
2602
2748
  const abortHubs = new Map<string, AbortHub>()
@@ -3276,7 +3422,9 @@ export function createServer<
3276
3422
  )
3277
3423
  }
3278
3424
 
3279
- const session = (id: string): Session<D, SessionAppend<D>, P> => {
3425
+ const session = (
3426
+ id: string,
3427
+ ): Session<D, SessionAppend<D>, P> & SessionDeadLetters<D> => {
3280
3428
  assertSessionId(id)
3281
3429
  const appendInline = async (...events: AppendInput<D>[]) =>
3282
3430
  appendExternal(id, 'inline', events)
@@ -3285,7 +3433,11 @@ export function createServer<
3285
3433
  const append = Object.assign(appendInline, {
3286
3434
  dispatch: appendDispatch,
3287
3435
  }) as SessionAppend<D>
3288
- return makeSession(id, append, null)
3436
+ return Object.assign(makeSession(id, append, null), {
3437
+ deadLetters: () => listDeadLetters(id),
3438
+ redrive: (index: number, redriveOptions: SessionRedriveOptions) =>
3439
+ redriveEvent(id, index, redriveOptions),
3440
+ })
3289
3441
  }
3290
3442
 
3291
3443
  const historyPage = async (
@@ -3393,6 +3545,12 @@ function assertStateIndex(value: number): void {
3393
3545
  }
3394
3546
  }
3395
3547
 
3548
+ function assertRedriveIndex(value: number): void {
3549
+ if (!Number.isSafeInteger(value) || value < 1) {
3550
+ throw new TypeError('redrive requires a positive safe-integer index')
3551
+ }
3552
+ }
3553
+
3396
3554
  function assertClearIndex(value: number): void {
3397
3555
  if (!Number.isSafeInteger(value) || value < 0) {
3398
3556
  throw new TypeError('ifLastIndex must be a non-negative safe integer')
@@ -259,16 +259,18 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
259
259
  return row
260
260
  }
261
261
 
262
- const appendResult = (sessionId: string, events: StoredEvent[]) => {
262
+ const hasPending = (sessionId: string): boolean => {
263
263
  const log = sessions.get(sessionId)
264
- return {
265
- events,
266
- hasPending: (log?.rows ?? []).some(
267
- (row) => row.index > log!.clearIndex && row.processedAt === null,
268
- ),
269
- }
264
+ return (log?.rows ?? []).some(
265
+ (row) => row.index > log!.clearIndex && row.processedAt === null,
266
+ )
270
267
  }
271
268
 
269
+ const appendResult = (sessionId: string, events: StoredEvent[]) => ({
270
+ events,
271
+ hasPending: hasPending(sessionId),
272
+ })
273
+
272
274
  /** The attempt-currency fence shared by handler appends and clears. */
273
275
  const assertCurrentCause = (sessionId: string, cause: EventCause): void => {
274
276
  if (cause.index <= clearIndexOf(sessionId)) {
@@ -719,6 +721,39 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
719
721
  return { outcome: 'failed', failureCount: row.failureCount }
720
722
  },
721
723
 
724
+ async deadLetters(sessionId) {
725
+ const log = sessions.get(sessionId)
726
+ return (log?.rows ?? [])
727
+ .filter(
728
+ (row) =>
729
+ row.index > log!.clearIndex &&
730
+ row.processedAt === null &&
731
+ row.failedAt !== null,
732
+ )
733
+ .map(toStored)
734
+ },
735
+
736
+ async redrive({ sessionId, index, action }) {
737
+ const row =
738
+ index > clearIndexOf(sessionId) ? rowAt(sessionId, index) : undefined
739
+ if (!row || row.processedAt !== null || row.failedAt === null) {
740
+ return { redriven: false, hasPending: hasPending(sessionId) }
741
+ }
742
+ if (action === 'skip') {
743
+ row.processedAt = clock.now()
744
+ settle(sessionId, row)
745
+ } else {
746
+ row.failedAt = null
747
+ row.failureCount = 0
748
+ const state = dispatchOf(sessionId)
749
+ const queue = row.lane === null ? undefined : state.lanes.get(row.lane)
750
+ if (row.lane === null || queue?.rows[queue.head] === row) {
751
+ state.ready.set(row.index, row)
752
+ }
753
+ }
754
+ return { redriven: true, hasPending: hasPending(sessionId) }
755
+ },
756
+
722
757
  async readState(sessionId, reducerName, stateOptions) {
723
758
  return readStateOf(
724
759
  sessionId,
@@ -1364,6 +1364,86 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1364
1364
  )
1365
1365
  },
1366
1366
 
1367
+ async deadLetters(sessionId) {
1368
+ return wrap(async () => {
1369
+ const c = await client()
1370
+ const { rows } = await c.query(
1371
+ `select * from a2_events
1372
+ where session_id = $1
1373
+ and processed_at is null
1374
+ and failed_at is not null
1375
+ and idx > ${clearIndexOf('$1')}
1376
+ order by idx`,
1377
+ [sessionId],
1378
+ )
1379
+ return rows.map(toStored)
1380
+ })
1381
+ },
1382
+
1383
+ async redrive({ sessionId, index, action }) {
1384
+ return wrap(() =>
1385
+ withTx(async (query) => {
1386
+ // Lane promotion must see every append that committed before it,
1387
+ // so a skip takes the session lock first, in append's order. The
1388
+ // row lock serializes both actions with claims and handler appends.
1389
+ if (action === 'skip') {
1390
+ await query('select pg_advisory_xact_lock(hashtext($1))', [
1391
+ sessionId,
1392
+ ])
1393
+ }
1394
+ const { rows } = await query(
1395
+ `select lane from a2_events
1396
+ where session_id = $1 and idx = $2
1397
+ and idx > ${clearIndexOf('$1')}
1398
+ and processed_at is null
1399
+ and failed_at is not null
1400
+ for update`,
1401
+ [sessionId, index],
1402
+ )
1403
+ const row = rows[0]
1404
+ if (row && action === 'retry') {
1405
+ await query(
1406
+ `update a2_events set failed_at = null, failure_count = 0
1407
+ where session_id = $1 and idx = $2`,
1408
+ [sessionId, index],
1409
+ )
1410
+ } else if (row) {
1411
+ await query(
1412
+ `update a2_events set processed_at = $3, lane_ready = false
1413
+ where session_id = $1 and idx = $2`,
1414
+ [sessionId, index, clock.now().getTime()],
1415
+ )
1416
+ const lane = (row['lane'] as string | null) ?? null
1417
+ if (lane !== null) {
1418
+ await query(
1419
+ `update a2_events set lane_ready = true
1420
+ where session_id = $1
1421
+ and idx = (
1422
+ select idx from a2_events
1423
+ where session_id = $1 and lane = $2 and processed_at is null
1424
+ and idx > ${clearIndexOf('$1')}
1425
+ order by idx limit 1
1426
+ )`,
1427
+ [sessionId, lane],
1428
+ )
1429
+ }
1430
+ }
1431
+ const { rows: status } = await query(
1432
+ `select exists(
1433
+ select 1 from a2_events
1434
+ where session_id = $1 and processed_at is null
1435
+ and idx > ${clearIndexOf('$1')}
1436
+ ) as has_pending`,
1437
+ [sessionId],
1438
+ )
1439
+ return {
1440
+ redriven: row !== undefined,
1441
+ hasPending: status[0]?.['has_pending'] === true,
1442
+ }
1443
+ }),
1444
+ )
1445
+ },
1446
+
1367
1447
  async readState(sessionId, reducerName, stateOptions) {
1368
1448
  return wrap(
1369
1449
  async () =>