experimental-a2 0.18.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 (114) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
  3. package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor.d.ts +1 -1
  8. package/dist/actor.js +2 -2
  9. package/dist/{ai-DrUaMyRt.d.ts → ai-BMLlI2Ff.d.ts} +2 -2
  10. package/dist/{ai-DrUaMyRt.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  11. package/dist/{ai-BpOGm-zB.js → ai-D6mGlO8j.js} +2 -2
  12. package/dist/{ai-BpOGm-zB.js.map → ai-D6mGlO8j.js.map} +1 -1
  13. package/dist/ai-client.d.ts +1 -1
  14. package/dist/ai-client.js +2 -2
  15. package/dist/ai-fx.d.ts +1 -1
  16. package/dist/ai-fx.d.ts.map +1 -1
  17. package/dist/ai-fx.js +92 -34
  18. package/dist/ai-fx.js.map +1 -1
  19. package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-B2suP2Qh.d.ts} +2 -2
  20. package/dist/{ai-harness-DvsPeJeB.d.ts.map → ai-harness-B2suP2Qh.d.ts.map} +1 -1
  21. package/dist/ai-pi.d.ts +1 -1
  22. package/dist/ai-pi.d.ts.map +1 -1
  23. package/dist/ai-pi.js +59 -13
  24. package/dist/ai-pi.js.map +1 -1
  25. package/dist/ai-react.d.ts +1 -1
  26. package/dist/ai-react.js +2 -2
  27. package/dist/ai-server.d.ts +3 -3
  28. package/dist/ai-server.d.ts.map +1 -1
  29. package/dist/ai-server.js +10 -6
  30. package/dist/ai-server.js.map +1 -1
  31. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  32. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  33. package/dist/ai.d.ts +1 -1
  34. package/dist/ai.js +1 -1
  35. package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
  36. package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
  37. package/dist/client.js +1 -1
  38. package/dist/index.d.ts +2 -2
  39. package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
  40. package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
  41. package/dist/react.js +1 -1
  42. package/dist/scheduler-qstash.d.ts +2 -2
  43. package/dist/scheduler-qstash.js +3 -3
  44. package/dist/scheduler-qstash.js.map +1 -1
  45. package/dist/scheduler-vercel.d.ts +7 -7
  46. package/dist/scheduler-vercel.d.ts.map +1 -1
  47. package/dist/scheduler-vercel.js +9 -10
  48. package/dist/scheduler-vercel.js.map +1 -1
  49. package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
  50. package/dist/server-D-Y4pqwu.js.map +1 -0
  51. package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
  52. package/dist/server-DYAATFuT.d.ts.map +1 -0
  53. package/dist/server.d.ts +2 -2
  54. package/dist/server.js +1 -1
  55. package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  56. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  57. package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
  58. package/dist/store-CyLUy86v.d.ts.map +1 -0
  59. package/dist/store-N8PXxDAS.js.map +1 -1
  60. package/dist/store-memory.d.ts +1 -1
  61. package/dist/store-memory.js +25 -6
  62. package/dist/store-memory.js.map +1 -1
  63. package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
  64. package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  65. package/dist/store-postgres.d.ts +1 -1
  66. package/dist/store-postgres.d.ts.map +1 -1
  67. package/dist/store-postgres.js +55 -25
  68. package/dist/store-postgres.js.map +1 -1
  69. package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
  70. package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  71. package/dist/store-redis-http.d.ts +1 -1
  72. package/dist/store-redis-http.js +3 -3
  73. package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
  74. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  75. package/dist/store-redis.d.ts +1 -1
  76. package/dist/store-redis.js +1 -1
  77. package/dist/store-sqlite.d.ts +1 -1
  78. package/dist/store-sqlite.js +35 -20
  79. package/dist/store-sqlite.js.map +1 -1
  80. package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
  81. package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
  82. package/docs/agents/03-conversations.mdx +2 -2
  83. package/docs/index.mdx +3 -3
  84. package/docs/reference/02-server.mdx +12 -7
  85. package/docs/reference/04-ai.mdx +16 -7
  86. package/docs/reference/05-adapters.mdx +4 -4
  87. package/docs/server/01-handlers.mdx +1 -1
  88. package/docs/server/03-cancellation.mdx +3 -2
  89. package/docs/server/05-production.mdx +2 -1
  90. package/docs/server/06-guarantees.mdx +20 -11
  91. package/examples/playground/DEPLOY.md +2 -4
  92. package/examples/playground/app/a0/[appId]/page.tsx +2 -8
  93. package/examples/playground/app/a0/devtools.tsx +2 -8
  94. package/examples/playground/app/a0/server.ts +8 -19
  95. package/examples/playground/package.json +1 -1
  96. package/package.json +1 -1
  97. package/src/ai-agent.ts +10 -3
  98. package/src/ai-fx.ts +126 -47
  99. package/src/ai-pi.ts +74 -15
  100. package/src/ai-tool-input.ts +45 -0
  101. package/src/internal.ts +6 -0
  102. package/src/scheduler-qstash.ts +5 -5
  103. package/src/scheduler-vercel.ts +14 -15
  104. package/src/server.ts +58 -12
  105. package/src/store-memory.ts +50 -9
  106. package/src/store-postgres.ts +79 -28
  107. package/src/store-redis-core.ts +112 -31
  108. package/src/store-sqlite.ts +61 -28
  109. package/src/store.ts +22 -3
  110. package/dist/server-Dw_k_7kO.js.map +0 -1
  111. package/dist/server-En7cbi3v.d.ts.map +0 -1
  112. package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
  113. package/dist/store-Djdv0bnZ.d.ts.map +0 -1
  114. package/dist/store-redis-notify-BRITti_o.js.map +0 -1
package/src/ai-pi.ts CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  type RecordedGeneration,
60
60
  saveHarness,
61
61
  } from './ai-harness.ts'
62
+ import { type SchemaInput, schemaInput } from './ai-tool-input.ts'
62
63
  import type { CompactionPartData, ToolResultPayload } from './ai.ts'
63
64
  import { boundedCache } from './bounded-cache.ts'
64
65
  import { ambientToolScopeStorage } from './internal.ts'
@@ -110,11 +111,14 @@ type PiModules = {
110
111
  ai: typeof import('@earendil-works/pi-ai')
111
112
  }
112
113
 
113
- /** A content block without the text the transcript already holds. */
114
+ /**
115
+ * A content block without the text the transcript already holds. A tool call
116
+ * keeps its arguments only where the transcript's input is not the model's.
117
+ */
114
118
  type StoredBlock =
115
119
  | (Omit<TextContent, 'text'> & { ref: string })
116
120
  | (Omit<ThinkingContent, 'thinking'> & { ref: string })
117
- | Omit<ToolCall, 'arguments'>
121
+ | (Omit<ToolCall, 'arguments'> & Partial<Pick<ToolCall, 'arguments'>>)
118
122
 
119
123
  /** What a step's transcript parts do not hold of Pi's response. */
120
124
  type StoredResponse = Omit<AssistantMessage, 'content'> & {
@@ -251,7 +255,7 @@ async function runStep(
251
255
  })
252
256
  const live = kind === 'step' ? liveStep() : undefined
253
257
  for await (const event of upstream) {
254
- live?.(event)
258
+ await live?.(event)
255
259
  if (event.type === 'done' || event.type === 'error') {
256
260
  const response = event.type === 'done' ? event.message : event.error
257
261
  if (kind === 'compaction') state.compactions.push(response)
@@ -267,11 +271,39 @@ async function runStep(
267
271
  }
268
272
  }
269
273
 
274
+ /**
275
+ * A live call's input as Pi's loop, then the AI SDK step, read it. Pi
276
+ * answers a call to no tool, or whose arguments it cannot prepare or
277
+ * validate, itself. The agent's tool reads the arguments Pi accepts through
278
+ * its own schema; A2 runs Pi's own tools from the model's arguments, as
279
+ * Pi's loop does.
280
+ */
281
+ const callInput = async (block: ToolCall): Promise<SchemaInput> => {
282
+ const definition = tools.find((tool) => tool.name === block.name)
283
+ if (definition === undefined)
284
+ return { success: false, errorText: `Tool ${block.name} not found` }
285
+ let args: unknown
286
+ try {
287
+ const prepared = definition.prepareArguments?.(block.arguments)
288
+ args = modules.ai.validateToolArguments(
289
+ definition,
290
+ prepared === undefined
291
+ ? block
292
+ : { ...block, arguments: prepared as JsonObject },
293
+ )
294
+ } catch (error) {
295
+ return { success: false, errorText: asError(error).message }
296
+ }
297
+ if (builtinOf(step.tools, block.name) !== undefined)
298
+ return { success: true, value: block.arguments }
299
+ return schemaInput({ tools: step.tools, toolName: block.name, value: args })
300
+ }
301
+
270
302
  /** Streams one live step response as UI chunks and records it. */
271
303
  const liveStep = () => {
272
304
  const ref = (index: number) => `pi:${step.requestIndex}:${index}`
273
305
  const started = new Set<string>()
274
- return (event: AssistantMessageEvent): void => {
306
+ return async (event: AssistantMessageEvent): Promise<void> => {
275
307
  switch (event.type) {
276
308
  case 'start':
277
309
  emit({ type: 'start-step' })
@@ -325,6 +357,8 @@ async function runStep(
325
357
  case 'done':
326
358
  case 'error': {
327
359
  const response = event.type === 'done' ? event.message : event.error
360
+ // Calls whose transcript input is not the model's arguments.
361
+ const changed = new Set<string>()
328
362
  if (event.type === 'done') {
329
363
  for (const block of response.content) {
330
364
  if (block.type !== 'toolCall') continue
@@ -335,26 +369,41 @@ async function runStep(
335
369
  toolName: block.name,
336
370
  })
337
371
  // Pi does not run the calls of a cut-off response.
338
- emit(
372
+ const input: SchemaInput =
339
373
  response.stopReason === 'length'
340
374
  ? {
341
- type: 'tool-input-error',
342
- toolCallId: block.id,
343
- toolName: block.name,
344
- input: block.arguments,
375
+ success: false,
345
376
  errorText:
346
377
  'The response hit the output token limit before this call was complete.',
347
378
  }
348
- : {
379
+ : await callInput(block)
380
+ if (
381
+ input.success &&
382
+ JSON.stringify(input.value) !== JSON.stringify(block.arguments)
383
+ )
384
+ changed.add(block.id)
385
+ emit(
386
+ input.success
387
+ ? {
349
388
  type: 'tool-input-available',
350
389
  toolCallId: block.id,
351
390
  toolName: block.name,
391
+ input: input.value,
392
+ }
393
+ : {
394
+ type: 'tool-input-error',
395
+ toolCallId: block.id,
396
+ toolName: block.name,
352
397
  input: block.arguments,
398
+ errorText: input.errorText,
353
399
  },
354
400
  )
355
401
  }
356
402
  }
357
- record({ call: 'step', response: storedResponse(response, ref) })
403
+ record({
404
+ call: 'step',
405
+ response: storedResponse(response, ref, changed),
406
+ })
358
407
  completion = {
359
408
  finishReason: finishReason(response.stopReason),
360
409
  usage: modelUsage(response.usage),
@@ -620,6 +669,7 @@ function recordedCalls(generation: RecordedGeneration): RecordedCall[] {
620
669
  function storedResponse(
621
670
  response: AssistantMessage,
622
671
  ref: (index: number) => string,
672
+ changed: ReadonlySet<string>,
623
673
  ): StoredResponse {
624
674
  return {
625
675
  ...response,
@@ -634,6 +684,7 @@ function storedResponse(
634
684
  return { ...rest, ref: ref(index) }
635
685
  }
636
686
  case 'toolCall': {
687
+ if (changed.has(block.id)) return block
637
688
  const { arguments: _, ...rest } = block
638
689
  return rest
639
690
  }
@@ -653,7 +704,8 @@ function restoredResponse(
653
704
  if (block.type === 'toolCall')
654
705
  return {
655
706
  ...block,
656
- arguments: (inputs.get(block.id) ?? {}) as JsonObject,
707
+ arguments:
708
+ block.arguments ?? ((inputs.get(block.id) ?? {}) as JsonObject),
657
709
  }
658
710
  const { ref, ...rest } = block
659
711
  const text = texts.get(ref) ?? ''
@@ -826,6 +878,15 @@ async function recordedResult(
826
878
  return { ...kept, details: { ...details, truncation } }
827
879
  }
828
880
 
881
+ /** The Pi tool a name in the agent's tool set runs, unless the agent replaced it. */
882
+ const builtinOf = (tools: ToolSet, name: string): Builtin | undefined =>
883
+ BUILTINS.find(
884
+ (candidate) =>
885
+ candidate === name &&
886
+ Object.hasOwn(tools, name) &&
887
+ tools[name] === builtinTools[name],
888
+ )
889
+
829
890
  // Each definition is typed by its parameters; a session takes any of them.
830
891
  const untyped = (definition: object) => definition as ToolDefinition
831
892
 
@@ -879,9 +940,7 @@ async function sessionTools(
879
940
  Object.entries(tools)
880
941
  .filter(([, tool]) => tool.type !== 'provider')
881
942
  .map(async ([name, tool]): Promise<ToolDefinition> => {
882
- const builtin = BUILTINS.find(
883
- (candidate) => candidate === name && builtinTools[name] === tool,
884
- )
943
+ const builtin = builtinOf(tools, name)
885
944
  if (builtin !== undefined)
886
945
  // Pi's own definition, for its prompt; A2 already ran the call.
887
946
  return Object.assign(builtinDefinition(modules, builtin, cwd), {
@@ -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
@@ -274,12 +274,18 @@ export const DRAIN_TIMINGS: {
274
274
  watchdogGraceMs: number
275
275
  /** Maximum time a top-level append waits for its initial scheduler arm. */
276
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
277
281
  } = {
278
282
  claimTtlMs: 5_000,
279
283
  claimHeartbeatMs: 2_000,
280
284
  renewRetryMs: 400,
281
285
  watchdogGraceMs: 1_000,
282
286
  schedulerArmTimeoutMs: 2_000,
287
+ failureRetryBaseMs: 1_000,
288
+ failureRetryMaxMs: 30_000,
283
289
  }
284
290
 
285
291
  /**
@@ -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
@@ -205,9 +205,15 @@ export type DeadLetter<D extends EventDefs> = ContractEvent<D> & {
205
205
  lane: string | null
206
206
  /** Durable dispatch claims, including claims abandoned by hard kills. */
207
207
  attemptCount: number
208
- /** Caught handler failures since the event was appended or last retried. */
208
+ /**
209
+ * Failed attempts since the event was appended or last retried: caught
210
+ * handler failures, and attempts that ended before they finished.
211
+ */
209
212
  failureCount: number
210
- /** The last handler failure, stringified. */
213
+ /**
214
+ * The last error a handler threw, stringified; null when every failed
215
+ * attempt ended before it finished.
216
+ */
211
217
  lastError: string | null
212
218
  failedAt: Date
213
219
  }
@@ -499,6 +505,13 @@ export type ServerOptions<
499
505
 
500
506
  const MAX_FAILURES = 10
501
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
+
502
515
  /** Abort a lost attempt's handler and release its drain slot. */
503
516
  function endSuperseded(
504
517
  entry: {
@@ -527,6 +540,7 @@ type ClaimWindow = {
527
540
  expiresAtMs: number
528
541
  watchdogAtMs: number
529
542
  deadlineCapped: boolean
543
+ pastDeadline: boolean
530
544
  }
531
545
 
532
546
  type DrainSignal = {
@@ -601,9 +615,11 @@ function pruneAbortHub(hub: AbortHub): void {
601
615
  }
602
616
 
603
617
  /**
604
- * Deadlines tighten the claim/watchdog window; they never decide whether a
605
- * handler starts. A platform timeout therefore follows the same path as any
606
- * 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.
607
623
  */
608
624
  function nextClaimWindow(): ClaimWindow {
609
625
  const nowMs = Date.now()
@@ -619,6 +635,7 @@ function nextClaimWindow(): ClaimWindow {
619
635
  expiresAtMs,
620
636
  watchdogAtMs: expiresAtMs + DRAIN_TIMINGS.watchdogGraceMs,
621
637
  deadlineCapped,
638
+ pastDeadline: deadlineMs !== null && deadlineTtlMs <= 0,
622
639
  }
623
640
  }
624
641
 
@@ -3068,6 +3085,25 @@ export function createServer<
3068
3085
  return tracked
3069
3086
  }
3070
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
+
3071
3107
  const runEvent = async (
3072
3108
  event: StoredEvent,
3073
3109
  supersedeSignal: AbortSignal,
@@ -3127,9 +3163,8 @@ export function createServer<
3127
3163
  if (supersedeSignal.aborted && err === supersedeSignal.reason) {
3128
3164
  // The handler surrendered to its own abort — identity, not
3129
3165
  // code, so it cannot arrive from another context. A lapse
3130
- // is not a handler failure: no completion, no failure
3131
- // budget spent; the claim simply expires and recovery
3132
- // retries.
3166
+ // is not a handler failure: no completion; the claim simply
3167
+ // expires, and the claim that takes it over counts it.
3133
3168
  eventSpan.setAttribute('a2.event.outcome', 'surrendered')
3134
3169
  return {
3135
3170
  status: 'surrendered',
@@ -3162,6 +3197,7 @@ export function createServer<
3162
3197
  // budget before dead-lettering.
3163
3198
  maxFailures:
3164
3199
  err instanceof NonRetriableError ? 1 : MAX_FAILURES,
3200
+ retryAfterMs: failureRetryDelayMs(event.failureCount + 1),
3165
3201
  })
3166
3202
  commitSpan.setAttribute('a2.commit.outcome', result.outcome)
3167
3203
  return result
@@ -3293,6 +3329,10 @@ export function createServer<
3293
3329
  let claim: StoreClaimAvailableResult | undefined
3294
3330
  if (shouldClaim) {
3295
3331
  const window = nextClaimWindow()
3332
+ if (window.pastDeadline) {
3333
+ outcome = 'busy'
3334
+ break
3335
+ }
3296
3336
  claim = await telemetry.span(
3297
3337
  'a2.claim',
3298
3338
  { 'a2.contract': name, 'a2.session_id': sessionId },
@@ -3301,6 +3341,7 @@ export function createServer<
3301
3341
  sessionId: ns,
3302
3342
  holder,
3303
3343
  ttlMs: window.ttlMs,
3344
+ maxFailures: MAX_FAILURES,
3304
3345
  ...(window.deadlineCapped
3305
3346
  ? { expiresAtMs: window.expiresAtMs }
3306
3347
  : {}),
@@ -3317,11 +3358,16 @@ export function createServer<
3317
3358
  },
3318
3359
  )
3319
3360
  if (claim.outcome === 'claimed') {
3320
- 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)
3321
3369
  if (window.deadlineCapped) stopRenewing = true
3322
- for (const event of claim.events) {
3323
- start(event, window.expiresAtMs)
3324
- }
3370
+ for (const event of runnable) start(event, window.expiresAtMs)
3325
3371
  shouldClaim = false
3326
3372
  }
3327
3373
  }
@@ -282,12 +282,35 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
282
282
  }
283
283
  }
284
284
 
285
- const claim = (
286
- row: Row,
287
- holder: string,
288
- now: Date,
289
- expiresAt: Date,
290
- ): StoredEvent => {
285
+ const claim = ({
286
+ sessionId,
287
+ row,
288
+ holder,
289
+ now,
290
+ expiresAt,
291
+ maxFailures,
292
+ }: {
293
+ sessionId: string
294
+ row: Row
295
+ holder: string
296
+ now: Date
297
+ expiresAt: Date
298
+ maxFailures: number | undefined
299
+ }): StoredEvent => {
300
+ // A claim still held here lapsed: its attempt never finished.
301
+ if (row.claimHolder !== null) {
302
+ row.failureCount += 1
303
+ if (maxFailures !== undefined && row.failureCount >= maxFailures) {
304
+ // Advancing the attempt fences the lapsed one; nothing runs.
305
+ row.attemptCount += 1
306
+ row.failedAt = new Date(now)
307
+ row.claimHolder = null
308
+ row.claimExpiresAt = null
309
+ dispatchOf(sessionId).ready.delete(row.index)
310
+ releaseSnapshotPins(sessionId, row.index)
311
+ return toStored(row)
312
+ }
313
+ }
291
314
  row.attemptCount += 1
292
315
  row.firstClaimedAt ??= new Date(now)
293
316
  row.lastClaimedAt = new Date(now)
@@ -547,6 +570,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
547
570
  ttlMs,
548
571
  expiresAtMs,
549
572
  excludeIndexes,
573
+ maxFailures,
550
574
  }) {
551
575
  const claimedAt = clock.now()
552
576
  const now = claimedAt.getTime()
@@ -574,7 +598,14 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
574
598
  return {
575
599
  outcome: 'claimed',
576
600
  events: eligible.map((row) => {
577
- const claimed = claim(row, holder, claimedAt, expiresAt)
601
+ const claimed = claim({
602
+ sessionId,
603
+ row,
604
+ holder,
605
+ now: claimedAt,
606
+ expiresAt,
607
+ maxFailures,
608
+ })
578
609
  if (clearIndex > 0) claimed.clearIndex = clearIndex
579
610
  return claimed
580
611
  }),
@@ -681,7 +712,14 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
681
712
  return { outcome: 'completed', events: inserted.map(toStored) }
682
713
  },
683
714
 
684
- async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
715
+ async failAttempt({
716
+ sessionId,
717
+ index,
718
+ attempt,
719
+ error,
720
+ maxFailures,
721
+ retryAfterMs,
722
+ }) {
685
723
  if (index <= clearIndexOf(sessionId)) {
686
724
  return { outcome: 'superseded', failureCount: 0 }
687
725
  }
@@ -708,7 +746,10 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
708
746
  row.lastFailedAt = new Date(failedAt)
709
747
  row.lastFailedAttempt = attempt
710
748
  row.claimHolder = null
711
- row.claimExpiresAt = null
749
+ row.claimExpiresAt =
750
+ retryAfterMs === undefined || row.failureCount >= maxFailures
751
+ ? null
752
+ : new Date(failedAt.getTime() + retryAfterMs)
712
753
  if (row.failureCount >= maxFailures) {
713
754
  row.failedAt = new Date(failedAt)
714
755
  dispatchOf(sessionId).ready.delete(row.index)