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.
- package/CHANGELOG.md +11 -0
- package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
- package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +1 -1
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-DrUaMyRt.d.ts → ai-BMLlI2Ff.d.ts} +2 -2
- package/dist/{ai-DrUaMyRt.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
- package/dist/{ai-BpOGm-zB.js → ai-D6mGlO8j.js} +2 -2
- package/dist/{ai-BpOGm-zB.js.map → ai-D6mGlO8j.js.map} +1 -1
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +92 -34
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-B2suP2Qh.d.ts} +2 -2
- package/dist/{ai-harness-DvsPeJeB.d.ts.map → ai-harness-B2suP2Qh.d.ts.map} +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +59 -13
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +10 -6
- package/dist/ai-server.js.map +1 -1
- package/dist/ai-tool-input-DmSqMUQi.js +40 -0
- package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
- package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
- package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
- package/dist/react.js +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +7 -7
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +9 -10
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
- package/dist/server-D-Y4pqwu.js.map +1 -0
- package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
- package/dist/server-DYAATFuT.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
- package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
- package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
- package/dist/store-CyLUy86v.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +25 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
- package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +55 -25
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
- package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
- package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +35 -20
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
- package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
- package/docs/agents/03-conversations.mdx +2 -2
- package/docs/index.mdx +3 -3
- package/docs/reference/02-server.mdx +12 -7
- package/docs/reference/04-ai.mdx +16 -7
- package/docs/reference/05-adapters.mdx +4 -4
- package/docs/server/01-handlers.mdx +1 -1
- package/docs/server/03-cancellation.mdx +3 -2
- package/docs/server/05-production.mdx +2 -1
- package/docs/server/06-guarantees.mdx +20 -11
- package/examples/playground/DEPLOY.md +2 -4
- package/examples/playground/app/a0/[appId]/page.tsx +2 -8
- package/examples/playground/app/a0/devtools.tsx +2 -8
- package/examples/playground/app/a0/server.ts +8 -19
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/ai-agent.ts +10 -3
- package/src/ai-fx.ts +126 -47
- package/src/ai-pi.ts +74 -15
- package/src/ai-tool-input.ts +45 -0
- package/src/internal.ts +6 -0
- package/src/scheduler-qstash.ts +5 -5
- package/src/scheduler-vercel.ts +14 -15
- package/src/server.ts +58 -12
- package/src/store-memory.ts +50 -9
- package/src/store-postgres.ts +79 -28
- package/src/store-redis-core.ts +112 -31
- package/src/store-sqlite.ts +61 -28
- package/src/store.ts +22 -3
- package/dist/server-Dw_k_7kO.js.map +0 -1
- package/dist/server-En7cbi3v.d.ts.map +0 -1
- package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
- package/dist/store-Djdv0bnZ.d.ts.map +0 -1
- 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
|
-
/**
|
|
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
|
-
|
|
372
|
+
const input: SchemaInput =
|
|
339
373
|
response.stopReason === 'length'
|
|
340
374
|
? {
|
|
341
|
-
|
|
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({
|
|
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:
|
|
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 =
|
|
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
|
/**
|
package/src/scheduler-qstash.ts
CHANGED
|
@@ -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
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
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',
|
package/src/scheduler-vercel.ts
CHANGED
|
@@ -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
|
|
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
|
|
48
|
-
* not a transport failure. The default transport classifies
|
|
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
|
|
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
|
|
359
|
-
// Ensure a later slot here
|
|
360
|
-
// durable delivery. If the queue is down, the
|
|
361
|
-
// current message unacked for visibility-timeout
|
|
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
|
-
//
|
|
376
|
-
// transport retry path.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
605
|
-
*
|
|
606
|
-
*
|
|
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
|
|
3131
|
-
//
|
|
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
|
-
|
|
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
|
|
3323
|
-
start(event, window.expiresAtMs)
|
|
3324
|
-
}
|
|
3370
|
+
for (const event of runnable) start(event, window.expiresAtMs)
|
|
3325
3371
|
shouldClaim = false
|
|
3326
3372
|
}
|
|
3327
3373
|
}
|
package/src/store-memory.ts
CHANGED
|
@@ -282,12 +282,35 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
282
282
|
}
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
const claim = (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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(
|
|
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({
|
|
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 =
|
|
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)
|