@vercel/factory 0.0.15 → 0.0.17

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 (214) hide show
  1. package/CHANGELOG.md +362 -0
  2. package/README.md +49 -261
  3. package/dist/agent-routes.d.mts +47 -3
  4. package/dist/agent-routes.mjs +28 -1
  5. package/dist/agent-routes.mjs.map +1 -1
  6. package/dist/api-contracts.d.mts +20 -1
  7. package/dist/api-contracts.mjs +2 -1
  8. package/dist/api-contracts.mjs.map +1 -1
  9. package/dist/api.d.mts +45 -2
  10. package/dist/api.mjs +199 -10
  11. package/dist/api.mjs.map +1 -1
  12. package/dist/approval-contracts.d.mts +6 -0
  13. package/dist/blob/index.d.mts +51 -14
  14. package/dist/blob/index.mjs +26 -10
  15. package/dist/blob/index.mjs.map +1 -1
  16. package/dist/budget.d.mts +7 -0
  17. package/dist/budget.mjs +6 -0
  18. package/dist/budget.mjs.map +1 -1
  19. package/dist/build-factory.d.mts +1 -0
  20. package/dist/change-verification/dispatch.d.mts +16 -3
  21. package/dist/change-verification/dispatch.mjs +45 -7
  22. package/dist/change-verification/dispatch.mjs.map +1 -1
  23. package/dist/change-verification/eve-tool.d.mts +2 -1
  24. package/dist/change-verification/eve-tool.mjs +35 -47
  25. package/dist/change-verification/eve-tool.mjs.map +1 -1
  26. package/dist/change-verification/result.mjs +130 -0
  27. package/dist/change-verification/result.mjs.map +1 -0
  28. package/dist/changes/eve-record-change.d.mts +2 -2
  29. package/dist/changes/eve-record-change.mjs +43 -9
  30. package/dist/changes/eve-record-change.mjs.map +1 -1
  31. package/dist/changes.d.mts +4 -3
  32. package/dist/changes.mjs +2 -2
  33. package/dist/changes.mjs.map +1 -1
  34. package/dist/client-events.d.mts +10 -3
  35. package/dist/client-events.mjs +6 -2
  36. package/dist/client-events.mjs.map +1 -1
  37. package/dist/client-stream.mjs +8 -2
  38. package/dist/client-stream.mjs.map +1 -1
  39. package/dist/client-transcript.mjs +5 -1
  40. package/dist/client-transcript.mjs.map +1 -1
  41. package/dist/client.d.mts +121 -12
  42. package/dist/client.mjs +117 -9
  43. package/dist/client.mjs.map +1 -1
  44. package/dist/code-review/contracts.d.mts +1 -0
  45. package/dist/code-review/eve-post-review.d.mts +4 -4
  46. package/dist/code-review/eve-post-review.mjs +29 -17
  47. package/dist/code-review/eve-post-review.mjs.map +1 -1
  48. package/dist/code-review/eve-review-comments.d.mts +13 -2
  49. package/dist/code-review/eve-review-comments.mjs +45 -11
  50. package/dist/code-review/eve-review-comments.mjs.map +1 -1
  51. package/dist/code-review/github-reporter.d.mts +2 -0
  52. package/dist/code-review/github-reporter.mjs +7 -5
  53. package/dist/code-review/github-reporter.mjs.map +1 -1
  54. package/dist/code-review.d.mts +3 -2
  55. package/dist/deepsec/eve-tool.mjs +3 -1
  56. package/dist/deepsec/eve-tool.mjs.map +1 -1
  57. package/dist/dispatch.d.mts +79 -8
  58. package/dist/dispatch.mjs +68 -9
  59. package/dist/dispatch.mjs.map +1 -1
  60. package/dist/eve/index.d.mts +70 -10
  61. package/dist/eve/index.mjs +93 -22
  62. package/dist/eve/index.mjs.map +1 -1
  63. package/dist/eve/invoke.mjs +24 -11
  64. package/dist/eve/invoke.mjs.map +1 -1
  65. package/dist/eve/session-client.d.mts +122 -4
  66. package/dist/eve/session-client.mjs +127 -13
  67. package/dist/eve/session-client.mjs.map +1 -1
  68. package/dist/eve/task-execution.d.mts +380 -0
  69. package/dist/eve/task-execution.mjs +57 -2
  70. package/dist/eve/task-execution.mjs.map +1 -1
  71. package/dist/eve/task-session.d.mts +44 -2
  72. package/dist/eve/task-session.mjs +44 -2
  73. package/dist/eve/task-session.mjs.map +1 -1
  74. package/dist/eve/transcript.mjs +5 -1
  75. package/dist/eve/transcript.mjs.map +1 -1
  76. package/dist/execution.d.mts +117 -9
  77. package/dist/execution.mjs +76 -6
  78. package/dist/execution.mjs.map +1 -1
  79. package/dist/finding-remediation/admission.d.mts +2 -0
  80. package/dist/finding-remediation/admission.mjs +4 -1
  81. package/dist/finding-remediation/admission.mjs.map +1 -1
  82. package/dist/findings.d.mts +1 -0
  83. package/dist/github-publication.d.mts +1 -0
  84. package/dist/github-publication.mjs +97 -84
  85. package/dist/github-publication.mjs.map +1 -1
  86. package/dist/github-transfer.d.mts +15 -6
  87. package/dist/github-transfer.mjs +214 -66
  88. package/dist/github-transfer.mjs.map +1 -1
  89. package/dist/github.d.mts +51 -11
  90. package/dist/github.mjs +126 -24
  91. package/dist/github.mjs.map +1 -1
  92. package/dist/inbox-activity.d.mts +53 -0
  93. package/dist/inbox-activity.mjs +41 -0
  94. package/dist/inbox-activity.mjs.map +1 -0
  95. package/dist/index.d.mts +3 -1
  96. package/dist/index.mjs +3 -2
  97. package/dist/intake-contracts.d.mts +0 -1
  98. package/dist/integrations/deepsec.d.mts +1 -0
  99. package/dist/integrations/github.d.mts +2 -2
  100. package/dist/integrations/github.mjs +2 -2
  101. package/dist/integrations/slack.d.mts +3 -1
  102. package/dist/integrations/slack.mjs +3 -1
  103. package/dist/integrations/vercel.d.mts +4 -2
  104. package/dist/integrations/vercel.mjs +3 -2
  105. package/dist/merge-resolution/eve-tools.d.mts +1 -0
  106. package/dist/merge-resolution/eve-tools.mjs +7 -2
  107. package/dist/merge-resolution/eve-tools.mjs.map +1 -1
  108. package/dist/model-settings.d.mts +41 -0
  109. package/dist/model-settings.mjs +35 -0
  110. package/dist/model-settings.mjs.map +1 -0
  111. package/dist/planning/reconcile.mjs +6 -0
  112. package/dist/planning/reconcile.mjs.map +1 -1
  113. package/dist/postgres/index.d.mts +43 -2
  114. package/dist/postgres/index.mjs +40 -2
  115. package/dist/postgres/index.mjs.map +1 -1
  116. package/dist/presets/software-development/dispatch.d.mts +4 -1
  117. package/dist/presets/software-development/dispatch.mjs +2 -1
  118. package/dist/presets/software-development/dispatch.mjs.map +1 -1
  119. package/dist/presets/software-development/task-communication.d.mts +1 -0
  120. package/dist/presets/software-development/task-communication.mjs +48 -11
  121. package/dist/presets/software-development/task-communication.mjs.map +1 -1
  122. package/dist/presets/software-development.d.mts +1 -0
  123. package/dist/pull-requests/github-publisher.d.mts +15 -1
  124. package/dist/pull-requests/github-publisher.mjs +61 -1
  125. package/dist/pull-requests/github-publisher.mjs.map +1 -1
  126. package/dist/pull-requests.d.mts +1 -0
  127. package/dist/sandbox/index.d.mts +1 -0
  128. package/dist/schema/agent-route.d.mts +19 -1
  129. package/dist/schema/agent-route.mjs +19 -1
  130. package/dist/schema/agent-route.mjs.map +1 -1
  131. package/dist/schema/factory-config.d.mts +27 -0
  132. package/dist/schema/factory-config.mjs +33 -3
  133. package/dist/schema/factory-config.mjs.map +1 -1
  134. package/dist/schema/repository.d.mts +4 -0
  135. package/dist/schema/repository.mjs +5 -1
  136. package/dist/schema/repository.mjs.map +1 -1
  137. package/dist/schema/session.d.mts +1 -0
  138. package/dist/schema/session.mjs +1 -0
  139. package/dist/schema/session.mjs.map +1 -1
  140. package/dist/schema/slack-pr-notifications.d.mts +12 -0
  141. package/dist/schema/slack-pr-notifications.mjs +11 -0
  142. package/dist/schema/slack-pr-notifications.mjs.map +1 -0
  143. package/dist/schema/task-graph.d.mts +39 -0
  144. package/dist/schema/task.d.mts +1 -0
  145. package/dist/schema/task.mjs +2 -1
  146. package/dist/schema/task.mjs.map +1 -1
  147. package/dist/schema/transcript.d.mts +6 -0
  148. package/dist/schema/transcript.mjs +2 -1
  149. package/dist/schema/transcript.mjs.map +1 -1
  150. package/dist/schema/work.d.mts +52 -3
  151. package/dist/schema/work.mjs.map +1 -1
  152. package/dist/session-previews.d.mts +76 -0
  153. package/dist/session-previews.mjs +55 -0
  154. package/dist/session-previews.mjs.map +1 -0
  155. package/dist/session-review.d.mts +120 -0
  156. package/dist/session-review.mjs +79 -0
  157. package/dist/session-review.mjs.map +1 -0
  158. package/dist/signal-triage.mjs +1 -1
  159. package/dist/signals.d.mts +1 -0
  160. package/dist/stall.d.mts +4 -1
  161. package/dist/stall.mjs +6 -2
  162. package/dist/stall.mjs.map +1 -1
  163. package/dist/store/driver.d.mts +1 -1
  164. package/dist/store/driver.mjs.map +1 -1
  165. package/dist/store/engine.d.mts +206 -8
  166. package/dist/store/engine.mjs +147 -13
  167. package/dist/store/engine.mjs.map +1 -1
  168. package/dist/store/memory.d.mts +18 -1
  169. package/dist/store/memory.mjs +18 -1
  170. package/dist/store/memory.mjs.map +1 -1
  171. package/dist/store/slack-pr-notifications.d.mts +44 -0
  172. package/dist/store/slack-pr-notifications.mjs +121 -0
  173. package/dist/store/slack-pr-notifications.mjs.map +1 -0
  174. package/dist/store/task-work.d.mts +121 -6
  175. package/dist/store/task-work.mjs +7 -4
  176. package/dist/store/task-work.mjs.map +1 -1
  177. package/dist/sweep.d.mts +28 -6
  178. package/dist/sweep.mjs +34 -6
  179. package/dist/sweep.mjs.map +1 -1
  180. package/dist/task-graph-view.d.mts +3 -0
  181. package/dist/tasks.d.mts +3 -3
  182. package/dist/tasks.mjs +3 -3
  183. package/dist/vercel-git.d.mts +35 -3
  184. package/dist/vercel-git.mjs +265 -33
  185. package/dist/vercel-git.mjs.map +1 -1
  186. package/dist/vercel-github-api.d.mts +103 -0
  187. package/dist/vercel-github-api.mjs +363 -0
  188. package/dist/vercel-github-api.mjs.map +1 -0
  189. package/dist/vercel.d.mts +3 -2
  190. package/dist/vercel.mjs +3 -2
  191. package/dist/vercel.mjs.map +1 -1
  192. package/dist/work-triage.d.mts +1 -0
  193. package/dist/workflows.d.mts +102 -4
  194. package/dist/workflows.mjs +55 -2
  195. package/dist/workflows.mjs.map +1 -1
  196. package/dist/workspace-files-git.d.mts +15 -0
  197. package/dist/workspace-files-git.mjs +61 -0
  198. package/dist/workspace-files-git.mjs.map +1 -0
  199. package/dist/workspace-files.d.mts +107 -0
  200. package/dist/workspace-files.mjs +74 -0
  201. package/dist/workspace-files.mjs.map +1 -0
  202. package/docs/getting-started.md +104 -0
  203. package/docs/index.md +100 -0
  204. package/docs/recipes/cancellation.md +215 -0
  205. package/docs/recipes/custom-workflow.md +153 -0
  206. package/docs/recipes/dependent-tasks.md +207 -0
  207. package/docs/recipes/eve-agent.md +277 -0
  208. package/docs/recipes/human-input.md +204 -0
  209. package/docs/recipes/persistence-recovery.md +268 -0
  210. package/docs/recipes/retry-recovery.md +241 -0
  211. package/docs/recipes/task-messaging.md +215 -0
  212. package/docs/recipes/typed-eve-result.md +161 -0
  213. package/docs/runtime-integration.md +137 -0
  214. package/package.json +20 -6
package/dist/dispatch.mjs CHANGED
@@ -3,12 +3,18 @@ import { InvalidTransitionError } from "./schema/transitions.mjs";
3
3
  import { routeKey } from "./agent-routes.mjs";
4
4
  import { RecordNotFoundError, VersionConflictError } from "./store/driver.mjs";
5
5
  //#region src/dispatch.ts
6
- /** The provider may have accepted the request. Keep this attempt for recovery using its original launch key. */
6
+ /**
7
+ * The provider may have accepted the request, so the running attempt must remain available for
8
+ * recovery with its original launch key. Catchers must not queue a replacement attempt blindly.
9
+ */
7
10
  var ExecutionLaunchPendingError = class extends Error {};
8
11
  /** Non-terminal dispatch refusal that identifies the gated Task and retryable reason. */
9
12
  var DispatchRefusedError = class extends Error {
13
+ /** Exact Task whose current dispatch attempt was refused. */
10
14
  taskId;
15
+ /** Stable gate or race category suitable for branching and telemetry. */
11
16
  code;
17
+ /** Human-readable refusal context; canonical Task state remains authoritative. */
12
18
  reason;
13
19
  constructor(taskId, code, reason) {
14
20
  super(`dispatch of "${taskId}" refused: ${reason}`);
@@ -24,8 +30,11 @@ var DispatchRefusedError = class extends Error {
24
30
  * the sweep. Operator retry recovers it once budget frees up.
25
31
  */
26
32
  var BudgetExhaustedError = class extends Error {
33
+ /** Exact Task already transitioned to `failed` by the exhausted admission. */
27
34
  taskId;
35
+ /** UTC budget month whose limit refused the reservation. */
28
36
  month;
37
+ /** Counted settled cost and live reservations observed for that month. */
29
38
  spendUsd;
30
39
  constructor(taskId, month, spendUsd) {
31
40
  super(`dispatch of "${taskId}" refused: budget for ${month} is exhausted at $${spendUsd}`);
@@ -38,11 +47,61 @@ var BudgetExhaustedError = class extends Error {
38
47
  /** Default launch-attempt allowance covering delayed identity provisioning. */
39
48
  const DEFAULT_MAX_LAUNCH_ATTEMPTS = 45;
40
49
  /**
41
- * The handshake between recorded work and execution: win the store-gated
42
- * transition (at most one dispatcher can succeed), then start the run. A
43
- * launch failure re-queues the task (bumping its attempt) until the attempt
44
- * limit, then fails it; a crash between the transition and the launch leaves
45
- * a running task with no execution, which the sweep reaps.
50
+ * Claims one queued Task and starts its exact execution mode.
51
+ *
52
+ * @remarks
53
+ * Dispatch first checks route, approval, dependency, and optional budget gates, then atomically
54
+ * transitions the Task from `queued` to `running`. Only the winner invokes `launch`. A successful
55
+ * provider dispatch returns only after its execution reference is stored; it does not mean the
56
+ * Task completed. `workflowOwned: true` returns the claimed running Task without starting a
57
+ * provider session.
58
+ *
59
+ * An ordinary launch failure is rethrown after a best-effort transition back to `queued`, or to
60
+ * `failed` once `maxLaunchAttempts` is exhausted. `ExecutionLaunchPendingError` instead leaves the
61
+ * attempt running because the provider may have accepted the request; recovery must inspect or
62
+ * repeat the launch with the original idempotency key. Budget exhaustion fails and receipts the
63
+ * Task before throwing `BudgetExhaustedError`. Since compensating transitions are best-effort,
64
+ * callers handling any thrown launch error should reread canonical Task state before deciding
65
+ * whether to schedule another attempt.
66
+ *
67
+ * @param stores - Task, graph, and receipt stores that own the dispatch gates.
68
+ * @param input - Exact Task identity plus either a launcher or workflow-owned marker.
69
+ * @returns The running Task, including its durably recorded execution when a launcher was used.
70
+ * @throws `RecordNotFoundError` when the Task is absent.
71
+ * @throws `DispatchRefusedError` when routing, approval, dependencies, or a budget-admission race refuses dispatch.
72
+ * @throws `InvalidTransitionError` when the Task is no longer queued when this caller claims it.
73
+ * @throws `BudgetExhaustedError` after the Task has been failed because no reservation fits.
74
+ * @throws `ExecutionLaunchPendingError` when provider acceptance is ambiguous and requires recovery.
75
+ * @see The shipped `docs/recipes/eve-agent.md` and `docs/recipes/retry-recovery.md` recipes.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * import {
80
+ * DispatchRefusedError,
81
+ * dispatchTask,
82
+ * type DispatchStores,
83
+ * } from "@vercel/factory/execution";
84
+ * import type { TaskId } from "@vercel/factory/tasks";
85
+ * import { parseAgentRouteBinding } from "@vercel/factory/workflows";
86
+ *
87
+ * declare const stores: DispatchStores;
88
+ * declare const taskId: TaskId;
89
+ * const route = parseAgentRouteBinding({ id: "worker", version: 1 });
90
+ *
91
+ * try {
92
+ * await dispatchTask(stores, {
93
+ * taskId,
94
+ * route,
95
+ * launch: async () => ({ provider: "eve", sessionId: "ses_example" }),
96
+ * });
97
+ * } catch (error) {
98
+ * if (error instanceof DispatchRefusedError) {
99
+ * // `error.code` identifies the gate; canonical Task state remains authoritative.
100
+ * } else {
101
+ * throw error;
102
+ * }
103
+ * }
104
+ * ```
46
105
  */
47
106
  async function dispatchTask(stores, input) {
48
107
  const { taskId } = input;
@@ -62,13 +121,13 @@ async function dispatchTask(stores, input) {
62
121
  if (options.budget !== void 0 && task.state === "queued") {
63
122
  const budgetNow = (options.budget.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
64
123
  const month = options.budget.month ?? budgetMonthOf(budgetNow);
65
- const rows = (options.budget.tasks ?? await stores.tasks.list()).map((row) => row.id === taskId ? {
124
+ const rows = (options.budget.enforce === false ? [] : options.budget.tasks ?? await stores.tasks.list()).map((row) => row.id === taskId ? {
66
125
  ...row,
67
126
  reservedUsd: void 0
68
127
  } : row);
69
128
  const spendUsd = computeMonthSpendUsd(rows, month);
70
129
  const { totalUsd, perTaskReservationUsd } = options.budget.limits;
71
- if (!canReserve(spendUsd, options.budget.limits)) {
130
+ if (options.budget.enforce !== false && !canReserve(spendUsd, options.budget.limits)) {
72
131
  const reason = `budget exhausted: ${month} spend $${spendUsd} cannot fit a $${perTaskReservationUsd} reservation under $${totalUsd}`;
73
132
  try {
74
133
  await stores.tasks.transition(taskId, "failed", {
@@ -103,7 +162,7 @@ async function dispatchTask(stores, input) {
103
162
  reservedUsd: reservation.reservedUsd,
104
163
  reservedAt: reservation.reservedAt
105
164
  } },
106
- ...task.attempt > 1 ? { phase: "retry" } : {}
165
+ ...task.attempt > 1 ? { phase: options.continuationAttempt === task.attempt ? "workflow" : "retry" } : {}
107
166
  });
108
167
  if (options.workflowOwned === true) return running;
109
168
  let execution;
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.mjs","names":[],"sources":["../src/dispatch.ts"],"sourcesContent":["import { budgetMonthOf, canReserve, computeMonthSpendUsd } from \"./budget\";\nimport type { AgentRouteBinding } from \"./agent-routes\";\nimport type { BudgetLimits, BudgetMonth } from \"./schema/budget\";\nimport type { DateClock } from \"./schema/common\";\nimport type { TaskId } from \"./schema/id\";\nimport type { ExecutionReference, Task } from \"./schema/task\";\nimport { routeKey } from \"./agent-routes\";\nimport { InvalidTransitionError } from \"./schema/transitions\";\nimport { RecordNotFoundError, VersionConflictError } from \"./store/driver\";\nimport type { FactoryStores } from \"./store/engine\";\n\n/** Minimal persistence capabilities required to gate and record one dispatch. */\nexport type DispatchStores = Pick<FactoryStores, \"graphs\" | \"receipts\" | \"tasks\">;\n\n/** The provider may have accepted the request. Keep this attempt for recovery using its original launch key. */\nexport class ExecutionLaunchPendingError extends Error {}\n\n/** Stable machine-readable reason a Task could not be dispatched. */\nexport type DispatchRefusalCode =\n | \"route_mismatch\"\n | \"awaiting_approval\"\n | \"approval_denied\"\n | \"dependencies_unsatisfied\"\n | \"dispatch_race\";\n\n/** Non-terminal dispatch refusal that identifies the gated Task and retryable reason. */\nexport class DispatchRefusedError extends Error {\n readonly taskId: TaskId;\n readonly code: DispatchRefusalCode;\n readonly reason: string;\n\n constructor(taskId: TaskId, code: DispatchRefusalCode, reason: string) {\n super(`dispatch of \"${taskId}\" refused: ${reason}`);\n this.name = \"DispatchRefusedError\";\n this.taskId = taskId;\n this.code = code;\n this.reason = reason;\n }\n}\n\n/**\n * An exhausted budget is definitive, not a retryable refusal: the task has\n * already been failed and receipted when this throws, so it never re-enters\n * the sweep. Operator retry recovers it once budget frees up.\n */\nexport class BudgetExhaustedError extends Error {\n readonly taskId: TaskId;\n readonly month: BudgetMonth;\n readonly spendUsd: number;\n\n constructor(taskId: TaskId, month: BudgetMonth, spendUsd: number) {\n super(`dispatch of \"${taskId}\" refused: budget for ${month} is exhausted at $${spendUsd}`);\n this.name = \"BudgetExhaustedError\";\n this.taskId = taskId;\n this.month = month;\n this.spendUsd = spendUsd;\n }\n}\n\n/** Factory-wide reservation limits and snapshot used to gate one dispatch. */\nexport interface DispatchBudget {\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\">;\n /** Defaults to the current UTC month. */\n month?: BudgetMonth;\n now?: DateClock;\n /** A dispatcher-wide snapshot used to calculate spend without another list. */\n tasks?: readonly Task[];\n}\n\ninterface DispatchContext {\n /** How many launches to try before the task fails for good; a failure re-queues for the next sweep. */\n maxLaunchAttempts?: number;\n /** Reserve-before-execution policy for this dispatch. */\n budget?: DispatchBudget;\n}\n\n/** Exact provider launch or explicit workflow-owned execution for one Task dispatch. */\nexport type DispatchOptions = DispatchContext &\n (\n | {\n /** Required exact binding when the Task declares an agent route. */\n route?: AgentRouteBinding;\n /**\n * Starts the durable execution for the task (an Eve agent run). Runs after\n * the task has won the queued -> running transition. Its provider reference\n * is mandatory so recovery can always find an accepted run.\n */\n launch: (task: Task) => Promise<ExecutionReference>;\n workflowOwned?: never;\n }\n | {\n /** Marks work whose workflow advances the running Task without a provider session. */\n workflowOwned: true;\n route?: never;\n launch?: never;\n }\n );\n\n/** Task identity and exact launch mode supplied to one dispatch attempt. */\nexport type DispatchTaskInput = { readonly taskId: TaskId } & DispatchOptions;\n\n// A fresh Vercel project rejects its own agents' sessions until its identity\n// finishes provisioning; at one sweep per minute the budget must outlast that.\n/** Default launch-attempt allowance covering delayed identity provisioning. */\nexport const DEFAULT_MAX_LAUNCH_ATTEMPTS = 45;\n\n/**\n * The handshake between recorded work and execution: win the store-gated\n * transition (at most one dispatcher can succeed), then start the run. A\n * launch failure re-queues the task (bumping its attempt) until the attempt\n * limit, then fails it; a crash between the transition and the launch leaves\n * a running task with no execution, which the sweep reaps.\n */\nexport async function dispatchTask(\n stores: DispatchStores,\n input: DispatchTaskInput,\n): Promise<Task> {\n const { taskId } = input;\n const options: DispatchOptions = input;\n const task = await stores.tasks.get(taskId);\n if (task === null) {\n throw new RecordNotFoundError(\"tasks\", taskId);\n }\n const route = task.work.route;\n if (route !== undefined && options.workflowOwned === true) {\n throw new DispatchRefusedError(\n taskId,\n \"route_mismatch\",\n `agent route ${routeKey(route)} requires a launcher`,\n );\n }\n if (\n route !== undefined &&\n (options.route?.id !== route.id || options.route?.version !== route.version)\n ) {\n throw new DispatchRefusedError(\n taskId,\n \"route_mismatch\",\n `launch requires exact agent route ${routeKey(route)}`,\n );\n }\n if (task.approval === \"required\") {\n throw new DispatchRefusedError(taskId, \"awaiting_approval\", \"awaiting human approval\");\n }\n if (task.approval === \"denied\") {\n throw new DispatchRefusedError(taskId, \"approval_denied\", \"approval was denied\");\n }\n if (\n !(await stores.graphs.dependenciesSatisfied({\n rootTaskId: task.rootTaskId,\n taskId: task.id,\n }))\n ) {\n throw new DispatchRefusedError(\n taskId,\n \"dependencies_unsatisfied\",\n \"graph dependencies must all be succeeded before dispatch\",\n );\n }\n\n // The reservation gates queued -> running only; any other state falls\n // through to the transition below, which rejects it as invalid.\n let reservation: { reservedUsd: number; reservedAt: string } | undefined;\n if (options.budget !== undefined && task.state === \"queued\") {\n const budgetNow = (options.budget.now ?? (() => new Date()))().toISOString();\n const month = options.budget.month ?? budgetMonthOf(budgetNow);\n // This task's own retained reservation from a prior attempt is excluded,\n // because re-reserving overwrites it. Its settled cost stays counted:\n // that money was actually spent and a retry must not reopen it.\n const rows = (options.budget.tasks ?? (await stores.tasks.list())).map((row) =>\n row.id === taskId ? { ...row, reservedUsd: undefined } : row,\n );\n const spendUsd = computeMonthSpendUsd(rows, month);\n const { totalUsd, perTaskReservationUsd } = options.budget.limits;\n if (!canReserve(spendUsd, options.budget.limits)) {\n const reason = `budget exhausted: ${month} spend $${spendUsd} cannot fit a $${perTaskReservationUsd} reservation under $${totalUsd}`;\n // Fail first, so BudgetExhaustedError's contract (task already failed)\n // holds; losing the task to a concurrent dispatcher surfaces as a\n // refusal, never as a false exhaustion with a misleading receipt.\n try {\n await stores.tasks.transition(taskId, \"failed\", { reason, expectFrom: \"queued\" });\n } catch (error) {\n if (error instanceof InvalidTransitionError || error instanceof VersionConflictError) {\n throw new DispatchRefusedError(\n taskId,\n \"dispatch_race\",\n \"another dispatcher took the task\",\n );\n }\n throw error;\n }\n // Best-effort: the failed transition above already wrote its own\n // receipt carrying this reason, so losing this supplementary one\n // cannot lose the evidence.\n await stores.receipts\n .append({\n repositoryIds: task.repositoryIds,\n taskId,\n kind: task.kind,\n state: \"reservation_refused\",\n phase: \"budget\",\n reason,\n attempt: task.attempt,\n dedupeKey: `task:${taskId}:reservation_refused:${task.attempt}`,\n })\n .catch(() => undefined);\n throw new BudgetExhaustedError(taskId, month, spendUsd);\n }\n reservation = { reservedUsd: perTaskReservationUsd, reservedAt: budgetNow };\n }\n\n const running = await stores.tasks.transition(taskId, \"running\", {\n expectFrom: \"queued\",\n expectAttempt: task.attempt,\n ...(reservation === undefined\n ? {}\n : {\n patch: {\n reservedUsd: reservation.reservedUsd,\n reservedAt: reservation.reservedAt,\n },\n }),\n ...(task.attempt > 1 ? { phase: \"retry\" } : {}),\n });\n if (options.workflowOwned === true) return running;\n let execution: ExecutionReference;\n try {\n execution = await options.launch(running);\n } catch (error) {\n if (error instanceof ExecutionLaunchPendingError) throw error;\n const reason = (error instanceof Error ? error.message : String(error)).slice(0, 500);\n const limit = options.maxLaunchAttempts ?? DEFAULT_MAX_LAUNCH_ATTEMPTS;\n if (running.attempt < limit) {\n const retryReason = `launch failed (attempt ${running.attempt} of ${limit}), will retry: ${reason}`;\n await stores.tasks\n .transition(taskId, \"queued\", {\n reason: retryReason,\n phase: \"retry\",\n expectFrom: \"running\",\n expectAttempt: running.attempt,\n })\n .catch(() => undefined);\n } else {\n await stores.tasks\n .transition(taskId, \"failed\", {\n reason: `launch failed after ${running.attempt} attempts: ${reason}`,\n phase: \"retry\",\n expectFrom: \"running\",\n expectAttempt: running.attempt,\n })\n .catch(() => undefined);\n }\n throw error;\n }\n // Surface a failed binding so a durable caller can recover the same launch;\n // never report acceptance with an unrecorded or stale execution.\n return stores.tasks.recordExecution(taskId, {\n execution,\n expectAttempt: running.attempt,\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"dispatch.mjs","names":[],"sources":["../src/dispatch.ts"],"sourcesContent":["import { budgetMonthOf, canReserve, computeMonthSpendUsd } from \"./budget\";\nimport type { AgentRouteBinding } from \"./agent-routes\";\nimport type { BudgetLimits, BudgetMonth } from \"./schema/budget\";\nimport type { DateClock } from \"./schema/common\";\nimport type { TaskId } from \"./schema/id\";\nimport type { ExecutionReference, Task } from \"./schema/task\";\nimport { routeKey } from \"./agent-routes\";\nimport { InvalidTransitionError } from \"./schema/transitions\";\nimport { RecordNotFoundError, VersionConflictError } from \"./store/driver\";\nimport type { FactoryStores } from \"./store/engine\";\n\n/** Minimal persistence capabilities required to gate and record one dispatch. */\nexport type DispatchStores = Pick<FactoryStores, \"graphs\" | \"receipts\" | \"tasks\">;\n\n/**\n * The provider may have accepted the request, so the running attempt must remain available for\n * recovery with its original launch key. Catchers must not queue a replacement attempt blindly.\n */\nexport class ExecutionLaunchPendingError extends Error {}\n\n/** Stable machine-readable reason a Task could not be dispatched. */\nexport type DispatchRefusalCode =\n | \"route_mismatch\"\n | \"awaiting_approval\"\n | \"approval_denied\"\n | \"dependencies_unsatisfied\"\n | \"dispatch_race\";\n\n/** Non-terminal dispatch refusal that identifies the gated Task and retryable reason. */\nexport class DispatchRefusedError extends Error {\n /** Exact Task whose current dispatch attempt was refused. */\n readonly taskId: TaskId;\n /** Stable gate or race category suitable for branching and telemetry. */\n readonly code: DispatchRefusalCode;\n /** Human-readable refusal context; canonical Task state remains authoritative. */\n readonly reason: string;\n\n constructor(taskId: TaskId, code: DispatchRefusalCode, reason: string) {\n super(`dispatch of \"${taskId}\" refused: ${reason}`);\n this.name = \"DispatchRefusedError\";\n this.taskId = taskId;\n this.code = code;\n this.reason = reason;\n }\n}\n\n/**\n * An exhausted budget is definitive, not a retryable refusal: the task has\n * already been failed and receipted when this throws, so it never re-enters\n * the sweep. Operator retry recovers it once budget frees up.\n */\nexport class BudgetExhaustedError extends Error {\n /** Exact Task already transitioned to `failed` by the exhausted admission. */\n readonly taskId: TaskId;\n /** UTC budget month whose limit refused the reservation. */\n readonly month: BudgetMonth;\n /** Counted settled cost and live reservations observed for that month. */\n readonly spendUsd: number;\n\n constructor(taskId: TaskId, month: BudgetMonth, spendUsd: number) {\n super(`dispatch of \"${taskId}\" refused: budget for ${month} is exhausted at $${spendUsd}`);\n this.name = \"BudgetExhaustedError\";\n this.taskId = taskId;\n this.month = month;\n this.spendUsd = spendUsd;\n }\n}\n\n/** Factory-wide reservation limits and snapshot used to gate one dispatch. */\nexport interface DispatchBudget {\n /** Factory-wide monthly ceiling and amount reserved for each admitted Task. */\n limits: Pick<BudgetLimits, \"totalUsd\" | \"perTaskReservationUsd\">;\n /** Defaults to true. False records reservations without enforcing the monthly ceiling. */\n enforce?: boolean;\n /** Defaults to the current UTC month. */\n month?: BudgetMonth;\n /** Clock used only to derive the reservation month and timestamp. Defaults to `new Date()`. */\n now?: DateClock;\n /** A dispatcher-wide snapshot used to calculate spend without another list. */\n tasks?: readonly Task[];\n}\n\ninterface DispatchContext {\n /** Exact workflow-confirmed continuation attempt; other replacement attempts count as retries. */\n continuationAttempt?: number;\n /**\n * Maximum Task attempt number allowed for launch failure retries. Defaults to 45. A normal\n * failure below the limit requeues the Task, which increments its attempt for the next sweep.\n */\n maxLaunchAttempts?: number;\n /** Reserve-before-execution policy for this dispatch. */\n budget?: DispatchBudget;\n}\n\n/** Exact provider launch or explicit workflow-owned execution for one Task dispatch. */\nexport type DispatchOptions = DispatchContext &\n (\n | {\n /** Required exact binding when the Task declares an agent route. */\n route?: AgentRouteBinding;\n /**\n * Starts the durable execution for the task (an Eve agent run). Runs after\n * the task has won the queued -> running transition. Its provider reference\n * is mandatory so recovery can always find an accepted run.\n */\n launch: (task: Task) => Promise<ExecutionReference>;\n workflowOwned?: never;\n }\n | {\n /**\n * Marks route-less work whose workflow advances the running Task without a provider\n * session. The caller must schedule that workflow after this admission returns.\n */\n workflowOwned: true;\n route?: never;\n launch?: never;\n }\n );\n\n/** Task identity and exact launch mode supplied to one dispatch attempt. */\nexport type DispatchTaskInput = { readonly taskId: TaskId } & DispatchOptions;\n\n// A fresh Vercel project rejects its own agents' sessions until its identity\n// finishes provisioning; at one sweep per minute the budget must outlast that.\n/** Default launch-attempt allowance covering delayed identity provisioning. */\nexport const DEFAULT_MAX_LAUNCH_ATTEMPTS = 45;\n\n/**\n * Claims one queued Task and starts its exact execution mode.\n *\n * @remarks\n * Dispatch first checks route, approval, dependency, and optional budget gates, then atomically\n * transitions the Task from `queued` to `running`. Only the winner invokes `launch`. A successful\n * provider dispatch returns only after its execution reference is stored; it does not mean the\n * Task completed. `workflowOwned: true` returns the claimed running Task without starting a\n * provider session.\n *\n * An ordinary launch failure is rethrown after a best-effort transition back to `queued`, or to\n * `failed` once `maxLaunchAttempts` is exhausted. `ExecutionLaunchPendingError` instead leaves the\n * attempt running because the provider may have accepted the request; recovery must inspect or\n * repeat the launch with the original idempotency key. Budget exhaustion fails and receipts the\n * Task before throwing `BudgetExhaustedError`. Since compensating transitions are best-effort,\n * callers handling any thrown launch error should reread canonical Task state before deciding\n * whether to schedule another attempt.\n *\n * @param stores - Task, graph, and receipt stores that own the dispatch gates.\n * @param input - Exact Task identity plus either a launcher or workflow-owned marker.\n * @returns The running Task, including its durably recorded execution when a launcher was used.\n * @throws `RecordNotFoundError` when the Task is absent.\n * @throws `DispatchRefusedError` when routing, approval, dependencies, or a budget-admission race refuses dispatch.\n * @throws `InvalidTransitionError` when the Task is no longer queued when this caller claims it.\n * @throws `BudgetExhaustedError` after the Task has been failed because no reservation fits.\n * @throws `ExecutionLaunchPendingError` when provider acceptance is ambiguous and requires recovery.\n * @see The shipped `docs/recipes/eve-agent.md` and `docs/recipes/retry-recovery.md` recipes.\n *\n * @example\n * ```ts\n * import {\n * DispatchRefusedError,\n * dispatchTask,\n * type DispatchStores,\n * } from \"@vercel/factory/execution\";\n * import type { TaskId } from \"@vercel/factory/tasks\";\n * import { parseAgentRouteBinding } from \"@vercel/factory/workflows\";\n *\n * declare const stores: DispatchStores;\n * declare const taskId: TaskId;\n * const route = parseAgentRouteBinding({ id: \"worker\", version: 1 });\n *\n * try {\n * await dispatchTask(stores, {\n * taskId,\n * route,\n * launch: async () => ({ provider: \"eve\", sessionId: \"ses_example\" }),\n * });\n * } catch (error) {\n * if (error instanceof DispatchRefusedError) {\n * // `error.code` identifies the gate; canonical Task state remains authoritative.\n * } else {\n * throw error;\n * }\n * }\n * ```\n */\nexport async function dispatchTask(\n stores: DispatchStores,\n input: DispatchTaskInput,\n): Promise<Task> {\n const { taskId } = input;\n const options: DispatchOptions = input;\n const task = await stores.tasks.get(taskId);\n if (task === null) {\n throw new RecordNotFoundError(\"tasks\", taskId);\n }\n const route = task.work.route;\n if (route !== undefined && options.workflowOwned === true) {\n throw new DispatchRefusedError(\n taskId,\n \"route_mismatch\",\n `agent route ${routeKey(route)} requires a launcher`,\n );\n }\n if (\n route !== undefined &&\n (options.route?.id !== route.id || options.route?.version !== route.version)\n ) {\n throw new DispatchRefusedError(\n taskId,\n \"route_mismatch\",\n `launch requires exact agent route ${routeKey(route)}`,\n );\n }\n if (task.approval === \"required\") {\n throw new DispatchRefusedError(taskId, \"awaiting_approval\", \"awaiting human approval\");\n }\n if (task.approval === \"denied\") {\n throw new DispatchRefusedError(taskId, \"approval_denied\", \"approval was denied\");\n }\n if (\n !(await stores.graphs.dependenciesSatisfied({\n rootTaskId: task.rootTaskId,\n taskId: task.id,\n }))\n ) {\n throw new DispatchRefusedError(\n taskId,\n \"dependencies_unsatisfied\",\n \"graph dependencies must all be succeeded before dispatch\",\n );\n }\n\n // The reservation gates queued -> running only; any other state falls\n // through to the transition below, which rejects it as invalid.\n let reservation: { reservedUsd: number; reservedAt: string } | undefined;\n if (options.budget !== undefined && task.state === \"queued\") {\n const budgetNow = (options.budget.now ?? (() => new Date()))().toISOString();\n const month = options.budget.month ?? budgetMonthOf(budgetNow);\n // This task's own retained reservation from a prior attempt is excluded,\n // because re-reserving overwrites it. Its settled cost stays counted:\n // that money was actually spent and a retry must not reopen it.\n const rows = (\n options.budget.enforce === false ? [] : (options.budget.tasks ?? (await stores.tasks.list()))\n ).map((row) => (row.id === taskId ? { ...row, reservedUsd: undefined } : row));\n const spendUsd = computeMonthSpendUsd(rows, month);\n const { totalUsd, perTaskReservationUsd } = options.budget.limits;\n if (options.budget.enforce !== false && !canReserve(spendUsd, options.budget.limits)) {\n const reason = `budget exhausted: ${month} spend $${spendUsd} cannot fit a $${perTaskReservationUsd} reservation under $${totalUsd}`;\n // Fail first, so BudgetExhaustedError's contract (task already failed)\n // holds; losing the task to a concurrent dispatcher surfaces as a\n // refusal, never as a false exhaustion with a misleading receipt.\n try {\n await stores.tasks.transition(taskId, \"failed\", { reason, expectFrom: \"queued\" });\n } catch (error) {\n if (error instanceof InvalidTransitionError || error instanceof VersionConflictError) {\n throw new DispatchRefusedError(\n taskId,\n \"dispatch_race\",\n \"another dispatcher took the task\",\n );\n }\n throw error;\n }\n // Best-effort: the failed transition above already wrote its own\n // receipt carrying this reason, so losing this supplementary one\n // cannot lose the evidence.\n await stores.receipts\n .append({\n repositoryIds: task.repositoryIds,\n taskId,\n kind: task.kind,\n state: \"reservation_refused\",\n phase: \"budget\",\n reason,\n attempt: task.attempt,\n dedupeKey: `task:${taskId}:reservation_refused:${task.attempt}`,\n })\n .catch(() => undefined);\n throw new BudgetExhaustedError(taskId, month, spendUsd);\n }\n reservation = { reservedUsd: perTaskReservationUsd, reservedAt: budgetNow };\n }\n\n const running = await stores.tasks.transition(taskId, \"running\", {\n expectFrom: \"queued\",\n expectAttempt: task.attempt,\n ...(reservation === undefined\n ? {}\n : {\n patch: {\n reservedUsd: reservation.reservedUsd,\n reservedAt: reservation.reservedAt,\n },\n }),\n ...(task.attempt > 1\n ? { phase: options.continuationAttempt === task.attempt ? \"workflow\" : \"retry\" }\n : {}),\n });\n if (options.workflowOwned === true) return running;\n let execution: ExecutionReference;\n try {\n execution = await options.launch(running);\n } catch (error) {\n if (error instanceof ExecutionLaunchPendingError) throw error;\n const reason = (error instanceof Error ? error.message : String(error)).slice(0, 500);\n const limit = options.maxLaunchAttempts ?? DEFAULT_MAX_LAUNCH_ATTEMPTS;\n if (running.attempt < limit) {\n const retryReason = `launch failed (attempt ${running.attempt} of ${limit}), will retry: ${reason}`;\n await stores.tasks\n .transition(taskId, \"queued\", {\n reason: retryReason,\n phase: \"retry\",\n expectFrom: \"running\",\n expectAttempt: running.attempt,\n })\n .catch(() => undefined);\n } else {\n await stores.tasks\n .transition(taskId, \"failed\", {\n reason: `launch failed after ${running.attempt} attempts: ${reason}`,\n phase: \"retry\",\n expectFrom: \"running\",\n expectAttempt: running.attempt,\n })\n .catch(() => undefined);\n }\n throw error;\n }\n // Surface a failed binding so a durable caller can recover the same launch;\n // never report acceptance with an unrecorded or stale execution.\n return stores.tasks.recordExecution(taskId, {\n execution,\n expectAttempt: running.attempt,\n });\n}\n"],"mappings":";;;;;;;;;AAkBA,IAAa,8BAAb,cAAiD,MAAM,CAAC;;AAWxD,IAAa,uBAAb,cAA0C,MAAM;;CAE9C;;CAEA;;CAEA;CAEA,YAAY,QAAgB,MAA2B,QAAgB;EACrE,MAAM,gBAAgB,OAAO,aAAa,QAAQ;EAClD,KAAK,OAAO;EACZ,KAAK,SAAS;EACd,KAAK,OAAO;EACZ,KAAK,SAAS;CAChB;AACF;;;;;;AAOA,IAAa,uBAAb,cAA0C,MAAM;;CAE9C;;CAEA;;CAEA;CAEA,YAAY,QAAgB,OAAoB,UAAkB;EAChE,MAAM,gBAAgB,OAAO,wBAAwB,MAAM,oBAAoB,UAAU;EACzF,KAAK,OAAO;EACZ,KAAK,SAAS;EACd,KAAK,QAAQ;EACb,KAAK,WAAW;CAClB;AACF;;AA2DA,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2D3C,eAAsB,aACpB,QACA,OACe;CACf,MAAM,EAAE,WAAW;CACnB,MAAM,UAA2B;CACjC,MAAM,OAAO,MAAM,OAAO,MAAM,IAAI,MAAM;CAC1C,IAAI,SAAS,MACX,MAAM,IAAI,oBAAoB,SAAS,MAAM;CAE/C,MAAM,QAAQ,KAAK,KAAK;CACxB,IAAI,UAAU,KAAA,KAAa,QAAQ,kBAAkB,MACnD,MAAM,IAAI,qBACR,QACA,kBACA,eAAe,SAAS,KAAK,EAAE,qBACjC;CAEF,IACE,UAAU,KAAA,MACT,QAAQ,OAAO,OAAO,MAAM,MAAM,QAAQ,OAAO,YAAY,MAAM,UAEpE,MAAM,IAAI,qBACR,QACA,kBACA,qCAAqC,SAAS,KAAK,GACrD;CAEF,IAAI,KAAK,aAAa,YACpB,MAAM,IAAI,qBAAqB,QAAQ,qBAAqB,yBAAyB;CAEvF,IAAI,KAAK,aAAa,UACpB,MAAM,IAAI,qBAAqB,QAAQ,mBAAmB,qBAAqB;CAEjF,IACE,CAAE,MAAM,OAAO,OAAO,sBAAsB;EAC1C,YAAY,KAAK;EACjB,QAAQ,KAAK;CACf,CAAC,GAED,MAAM,IAAI,qBACR,QACA,4BACA,0DACF;CAKF,IAAI;CACJ,IAAI,QAAQ,WAAW,KAAA,KAAa,KAAK,UAAU,UAAU;EAC3D,MAAM,aAAa,QAAQ,OAAO,8BAAc,IAAI,KAAK,GAAA,CAAI,CAAC,CAAC,YAAY;EAC3E,MAAM,QAAQ,QAAQ,OAAO,SAAS,cAAc,SAAS;EAI7D,MAAM,QACJ,QAAQ,OAAO,YAAY,QAAQ,CAAC,IAAK,QAAQ,OAAO,SAAU,MAAM,OAAO,MAAM,KAAK,EAAA,CAC1F,KAAK,QAAS,IAAI,OAAO,SAAS;GAAE,GAAG;GAAK,aAAa,KAAA;EAAU,IAAI,GAAI;EAC7E,MAAM,WAAW,qBAAqB,MAAM,KAAK;EACjD,MAAM,EAAE,UAAU,0BAA0B,QAAQ,OAAO;EAC3D,IAAI,QAAQ,OAAO,YAAY,SAAS,CAAC,WAAW,UAAU,QAAQ,OAAO,MAAM,GAAG;GACpF,MAAM,SAAS,qBAAqB,MAAM,UAAU,SAAS,iBAAiB,sBAAsB,sBAAsB;GAI1H,IAAI;IACF,MAAM,OAAO,MAAM,WAAW,QAAQ,UAAU;KAAE;KAAQ,YAAY;IAAS,CAAC;GAClF,SAAS,OAAO;IACd,IAAI,iBAAiB,0BAA0B,iBAAiB,sBAC9D,MAAM,IAAI,qBACR,QACA,iBACA,kCACF;IAEF,MAAM;GACR;GAIA,MAAM,OAAO,SACV,OAAO;IACN,eAAe,KAAK;IACpB;IACA,MAAM,KAAK;IACX,OAAO;IACP,OAAO;IACP;IACA,SAAS,KAAK;IACd,WAAW,QAAQ,OAAO,uBAAuB,KAAK;GACxD,CAAC,CAAC,CACD,YAAY,KAAA,CAAS;GACxB,MAAM,IAAI,qBAAqB,QAAQ,OAAO,QAAQ;EACxD;EACA,cAAc;GAAE,aAAa;GAAuB,YAAY;EAAU;CAC5E;CAEA,MAAM,UAAU,MAAM,OAAO,MAAM,WAAW,QAAQ,WAAW;EAC/D,YAAY;EACZ,eAAe,KAAK;EACpB,GAAI,gBAAgB,KAAA,IAChB,CAAC,IACD,EACE,OAAO;GACL,aAAa,YAAY;GACzB,YAAY,YAAY;EAC1B,EACF;EACJ,GAAI,KAAK,UAAU,IACf,EAAE,OAAO,QAAQ,wBAAwB,KAAK,UAAU,aAAa,QAAQ,IAC7E,CAAC;CACP,CAAC;CACD,IAAI,QAAQ,kBAAkB,MAAM,OAAO;CAC3C,IAAI;CACJ,IAAI;EACF,YAAY,MAAM,QAAQ,OAAO,OAAO;CAC1C,SAAS,OAAO;EACd,IAAI,iBAAiB,6BAA6B,MAAM;EACxD,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAA,CAAG,MAAM,GAAG,GAAG;EACpF,MAAM,QAAQ,QAAQ,qBAAA;EACtB,IAAI,QAAQ,UAAU,OAAO;GAC3B,MAAM,cAAc,0BAA0B,QAAQ,QAAQ,MAAM,MAAM,iBAAiB;GAC3F,MAAM,OAAO,MACV,WAAW,QAAQ,UAAU;IAC5B,QAAQ;IACR,OAAO;IACP,YAAY;IACZ,eAAe,QAAQ;GACzB,CAAC,CAAC,CACD,YAAY,KAAA,CAAS;EAC1B,OACE,MAAM,OAAO,MACV,WAAW,QAAQ,UAAU;GAC5B,QAAQ,uBAAuB,QAAQ,QAAQ,aAAa;GAC5D,OAAO;GACP,YAAY;GACZ,eAAe,QAAQ;EACzB,CAAC,CAAC,CACD,YAAY,KAAA,CAAS;EAE1B,MAAM;CACR;CAGA,OAAO,OAAO,MAAM,gBAAgB,QAAQ;EAC1C;EACA,eAAe,QAAQ;CACzB,CAAC;AACH"}
@@ -108,18 +108,45 @@ interface AppendReceiptTool extends ToolDefinition<AppendReceiptToolInput, Recei
108
108
  }
109
109
  /** The provider-neutral Task, Signal, question, and Receipt tools exposed to an Eve agent. */
110
110
  interface TaskTools {
111
+ /** Read one Task by ID; applications decide which agents may expose this cross-Task lookup. */
111
112
  readonly get_task: GetTaskTool;
113
+ /** Record the one final decision for a pending Signal. */
112
114
  readonly decide_signal: DecideSignalTool;
115
+ /** Complete generic `task@1` work after authenticating the current Eve execution. */
113
116
  readonly finish_task: FinishTaskTool;
117
+ /** Persist and present one human question for the authenticated running Task. */
114
118
  readonly request_human_input: RequestHumanInputTool;
119
+ /** Answer a question only from its authenticated reply conversation. */
115
120
  readonly answer_question: AnswerQuestionTool;
121
+ /** Append immutable application audit evidence with optional deduplication. */
116
122
  readonly append_receipt: AppendReceiptTool;
117
123
  }
118
124
  /**
119
- * The factory's provider-neutral store operations wrapped as Eve tools. Expose a tool to an
120
- * agent by re-exporting it from a file under agent/tools/, e.g.
121
- * `export default createTaskTools(stores).get_task;`. Every write goes through
122
- * the engine, so agents inherit transition legality and receipts.
125
+ * Wraps provider-neutral Factory store operations as Eve tools.
126
+ *
127
+ * @remarks
128
+ * Expose only the returned tools each agent role needs. Every write goes through the validating
129
+ * engine, but tool visibility remains application authorization policy. `finish_task` and
130
+ * `request_human_input` recheck the current authenticated Task attempt and Eve session before
131
+ * mutating state. `get_task`, signal decisions, and generic receipts are broader capabilities and
132
+ * should be mounted only for agents allowed to use them.
133
+ *
134
+ * The generic `finish_task` records `{ summary }` for `task@1`. Schema-specific workflows should
135
+ * define a narrow tool that calls `requireTaskExecution` and `stores.work.completeWorkflow`, as in
136
+ * the shipped `docs/recipes/typed-eve-result.md` recipe.
137
+ *
138
+ * @param stores - Receipt, Signal, and Task capabilities available to the tool bundle.
139
+ * @param options - Optional question presentation and generic-completion policy.
140
+ * @returns A new named Eve tool bundle ready for selective mounting under `agent/tools/`.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * import { createTaskTools, type TaskToolsStores } from "@vercel/factory";
145
+ *
146
+ * declare const stores: TaskToolsStores;
147
+ * const tools = createTaskTools(stores);
148
+ * export default tools.get_task;
149
+ * ```
123
150
  */
124
151
  declare function createTaskTools(stores: TaskToolsStores, options?: TaskToolsOptions): TaskTools;
125
152
  /** Completed conversation output, or `false` when the Task is not a conversation. */
@@ -134,6 +161,11 @@ interface FactoryHooksOptions {
134
161
  taskAttemptAttribute?: string;
135
162
  /** Attempts allowed before an explicit provider failure becomes terminal. Default: 3. */
136
163
  maxAttempts?: number;
164
+ /**
165
+ * Recover a saved workflow result for the exact bound execution before failure retry or
166
+ * unresolved-turn escalation. Return true when handled; a throw defers generic lifecycle changes.
167
+ */
168
+ recoverResult?: (task: Task) => Promise<boolean>;
137
169
  /** Present the recovery question created when a task turn ends without an outcome. */
138
170
  presentQuestion: PresentQuestion;
139
171
  /** Classify discussion Tasks on failure or cancellation without collecting a successful response. */
@@ -150,30 +182,58 @@ type TranscriptSnapshotHook = HookDefinition<"session.waiting" | "session.comple
150
182
  type FactoryHookEvent = "step.completed" | "turn.failed" | "session.failed" | "session.waiting" | "session.completed" | "turn.cancelled" | "turn.completed";
151
183
  /** The Eve lifecycle hook projected onto durable Factory Task state. */
152
184
  interface FactoryHooks {
185
+ /** Hook definition to export from the target Eve agent's `hooks/` directory. */
153
186
  readonly factory: HookDefinition<FactoryHookEvent>;
154
187
  }
155
188
  /**
156
- * Observe-only eve hooks that keep the ledger honest about session outcomes.
157
- * Mount by re-exporting from agent/hooks/, e.g.
158
- * `export default createFactoryHooks(stores, { presentQuestion }).factory;`.
189
+ * Creates observe-only Eve hooks that project session outcomes onto durable Factory state.
159
190
  *
191
+ * @remarks
160
192
  * step.completed: records the step's tokens and AI Gateway cost on the bound
161
- * task. Delivery is at least once; recordUsage dedupes by (turnId, stepIndex).
193
+ * task. Delivery is at least once; recordUsage dedupes by (sessionId, turnId, stepIndex).
162
194
  *
163
- * turn.failed/session.failed: immediately re-queues the current attempt, or
195
+ * turn.failed/session.failed: first recovers an opted-in saved workflow result, then re-queues the current attempt, or
164
196
  * fails it when its configured attempts are exhausted. turn.cancelled marks
165
197
  * the current Task cancelled. Late events from prior attempts are ignored.
166
198
  *
167
- * turn.completed: settles the bound task's recorded usage if the task is
199
+ * turn.completed: recovers an opted-in saved workflow result before generic escalation, and
200
+ * settles the bound task's recorded usage if the task is
168
201
  * already terminal, and enforces the turn contract otherwise: a still-running
169
202
  * bound task (or a still-running task at this session's reply address, for
170
203
  * conversational continuations that never got their own bound session)
171
204
  * escalates immediately to a surfaced human question instead of waiting out
172
205
  * the stall window, because the agent finished without recording an outcome.
206
+ * When the bound session matches the persisted execution, that question's work input includes
207
+ * `systemReason: "unresolved_turn"` and `completedSessionId`. Application recovery can distinguish
208
+ * this completed execution from an agent's genuine request for human input.
173
209
  *
174
210
  * Every store call is swallowed: a hook throw fails the turn.
175
211
  * Transcript projection writes at session response boundaries are likewise
176
212
  * best-effort; the API can rebuild a missing projection from durable events.
213
+ *
214
+ * Export the returned `factory` hook from the target agent's `hooks/` directory. The hook reads
215
+ * the Task ID and attempt written by `withFactoryTask`; late events from replaced attempts cannot
216
+ * advance current work. Provider failure recovery requeues below `maxAttempts` and fails the Task
217
+ * at the limit. A clean turn that records no outcome creates a human question instead of silently
218
+ * retrying the same brief.
219
+ *
220
+ * @param stores - Task store used for usage, recovery, cancellation, and completion projection.
221
+ * @param options - Attempt limits plus application-owned question and conversation callbacks.
222
+ * @returns An Eve hook definition under the `factory` property.
223
+ * @see The shipped `docs/recipes/retry-recovery.md` recipe for bounded replacement attempts.
224
+ *
225
+ * @example
226
+ * ```ts
227
+ * import { createFactoryHooks, type FactoryHooksStores } from "@vercel/factory";
228
+ *
229
+ * declare const stores: FactoryHooksStores;
230
+ * const hooks = createFactoryHooks(stores, {
231
+ * presentQuestion: async (questionTask) => {
232
+ * // Deliver the persisted question through the application's operator channel.
233
+ * },
234
+ * });
235
+ * export default hooks.factory;
236
+ * ```
177
237
  */
178
238
  declare function createFactoryHooks(stores: FactoryHooksStores, options: FactoryHooksOptions): FactoryHooks;
179
239
  //#endregion
@@ -1,9 +1,9 @@
1
1
  import { changeIdSchema, repositoryIdsSchema, signalIdSchema, taskIdSchema } from "../schema/id.mjs";
2
2
  import { taskWork } from "../schema/work.mjs";
3
3
  import { taskQuestionSchema } from "../schema/task.mjs";
4
- import { receiptKindSchema, receiptStateSchema } from "../schema/receipt.mjs";
5
4
  import { conversationBinding, conversationBindingForSession, conversationHeaders, factoryReplyAddressAttribute, factoryReplyAddressHeader, factoryReplyChannelAttribute, factoryReplyChannelHeader, factoryRepositoryIdsAttribute, factoryRepositoryIdsHeader, factoryTaskAttemptAttribute, factoryTaskAttemptHeader, factoryTaskAttribute, factoryTaskHeader, hasFactoryTaskBinding, optionalTaskIdForSession, replyToForSession, repositoryIdsForSession, requireTaskIdForSession, taskBindingForSession, taskHeaders, taskSessionAuth, withFactoryTask, withOptionalFactoryTask } from "./task-session.mjs";
6
5
  import { requireTaskExecution } from "./task-execution.mjs";
6
+ import { receiptKindSchema, receiptStateSchema } from "../schema/receipt.mjs";
7
7
  import { createEveTranscriptMaterializer } from "./transcript.mjs";
8
8
  import { cancelEveSession, forwardEveSessionRequest, readCompletedEveTurn, startEveSession } from "./session-client.mjs";
9
9
  import { createDelegateAgentTool, createInvokeAgentTool } from "./invoke.mjs";
@@ -18,7 +18,7 @@ const decidedOutcomeSchema = z.enum([
18
18
  "low_confidence",
19
19
  "ignored"
20
20
  ]);
21
- async function createAndPresentQuestion(stores, parent, question, presentQuestion) {
21
+ async function createAndPresentQuestion(stores, parent, question, presentQuestion, completedSessionId) {
22
22
  const needsHumanAction = question.text.trim();
23
23
  if (needsHumanAction === "") throw new Error("A human question must contain non-blank text.");
24
24
  const task = await stores.tasks.create({
@@ -26,7 +26,13 @@ async function createAndPresentQuestion(stores, parent, question, presentQuestio
26
26
  kind: "question",
27
27
  work: taskWork({
28
28
  title: needsHumanAction.slice(0, 256),
29
- input: { question }
29
+ input: {
30
+ question,
31
+ ...completedSessionId ? {
32
+ systemReason: "unresolved_turn",
33
+ completedSessionId
34
+ } : {}
35
+ }
30
36
  }),
31
37
  parentTaskId: parent.id,
32
38
  parentTaskAttempt: parent.attempt,
@@ -68,10 +74,31 @@ async function createAndPresentQuestion(stores, parent, question, presentQuestio
68
74
  }
69
75
  }
70
76
  /**
71
- * The factory's provider-neutral store operations wrapped as Eve tools. Expose a tool to an
72
- * agent by re-exporting it from a file under agent/tools/, e.g.
73
- * `export default createTaskTools(stores).get_task;`. Every write goes through
74
- * the engine, so agents inherit transition legality and receipts.
77
+ * Wraps provider-neutral Factory store operations as Eve tools.
78
+ *
79
+ * @remarks
80
+ * Expose only the returned tools each agent role needs. Every write goes through the validating
81
+ * engine, but tool visibility remains application authorization policy. `finish_task` and
82
+ * `request_human_input` recheck the current authenticated Task attempt and Eve session before
83
+ * mutating state. `get_task`, signal decisions, and generic receipts are broader capabilities and
84
+ * should be mounted only for agents allowed to use them.
85
+ *
86
+ * The generic `finish_task` records `{ summary }` for `task@1`. Schema-specific workflows should
87
+ * define a narrow tool that calls `requireTaskExecution` and `stores.work.completeWorkflow`, as in
88
+ * the shipped `docs/recipes/typed-eve-result.md` recipe.
89
+ *
90
+ * @param stores - Receipt, Signal, and Task capabilities available to the tool bundle.
91
+ * @param options - Optional question presentation and generic-completion policy.
92
+ * @returns A new named Eve tool bundle ready for selective mounting under `agent/tools/`.
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * import { createTaskTools, type TaskToolsStores } from "@vercel/factory";
97
+ *
98
+ * declare const stores: TaskToolsStores;
99
+ * const tools = createTaskTools(stores);
100
+ * export default tools.get_task;
101
+ * ```
75
102
  */
76
103
  function createTaskTools(stores, options = {}) {
77
104
  return {
@@ -224,27 +251,54 @@ const UNRESOLVED_TURN_QUESTION = {
224
251
  closingOptions: ["Close the task"]
225
252
  };
226
253
  /**
227
- * Observe-only eve hooks that keep the ledger honest about session outcomes.
228
- * Mount by re-exporting from agent/hooks/, e.g.
229
- * `export default createFactoryHooks(stores, { presentQuestion }).factory;`.
254
+ * Creates observe-only Eve hooks that project session outcomes onto durable Factory state.
230
255
  *
256
+ * @remarks
231
257
  * step.completed: records the step's tokens and AI Gateway cost on the bound
232
- * task. Delivery is at least once; recordUsage dedupes by (turnId, stepIndex).
258
+ * task. Delivery is at least once; recordUsage dedupes by (sessionId, turnId, stepIndex).
233
259
  *
234
- * turn.failed/session.failed: immediately re-queues the current attempt, or
260
+ * turn.failed/session.failed: first recovers an opted-in saved workflow result, then re-queues the current attempt, or
235
261
  * fails it when its configured attempts are exhausted. turn.cancelled marks
236
262
  * the current Task cancelled. Late events from prior attempts are ignored.
237
263
  *
238
- * turn.completed: settles the bound task's recorded usage if the task is
264
+ * turn.completed: recovers an opted-in saved workflow result before generic escalation, and
265
+ * settles the bound task's recorded usage if the task is
239
266
  * already terminal, and enforces the turn contract otherwise: a still-running
240
267
  * bound task (or a still-running task at this session's reply address, for
241
268
  * conversational continuations that never got their own bound session)
242
269
  * escalates immediately to a surfaced human question instead of waiting out
243
270
  * the stall window, because the agent finished without recording an outcome.
271
+ * When the bound session matches the persisted execution, that question's work input includes
272
+ * `systemReason: "unresolved_turn"` and `completedSessionId`. Application recovery can distinguish
273
+ * this completed execution from an agent's genuine request for human input.
244
274
  *
245
275
  * Every store call is swallowed: a hook throw fails the turn.
246
276
  * Transcript projection writes at session response boundaries are likewise
247
277
  * best-effort; the API can rebuild a missing projection from durable events.
278
+ *
279
+ * Export the returned `factory` hook from the target agent's `hooks/` directory. The hook reads
280
+ * the Task ID and attempt written by `withFactoryTask`; late events from replaced attempts cannot
281
+ * advance current work. Provider failure recovery requeues below `maxAttempts` and fails the Task
282
+ * at the limit. A clean turn that records no outcome creates a human question instead of silently
283
+ * retrying the same brief.
284
+ *
285
+ * @param stores - Task store used for usage, recovery, cancellation, and completion projection.
286
+ * @param options - Attempt limits plus application-owned question and conversation callbacks.
287
+ * @returns An Eve hook definition under the `factory` property.
288
+ * @see The shipped `docs/recipes/retry-recovery.md` recipe for bounded replacement attempts.
289
+ *
290
+ * @example
291
+ * ```ts
292
+ * import { createFactoryHooks, type FactoryHooksStores } from "@vercel/factory";
293
+ *
294
+ * declare const stores: FactoryHooksStores;
295
+ * const hooks = createFactoryHooks(stores, {
296
+ * presentQuestion: async (questionTask) => {
297
+ * // Deliver the persisted question through the application's operator channel.
298
+ * },
299
+ * });
300
+ * export default hooks.factory;
301
+ * ```
248
302
  */
249
303
  function createFactoryHooks(stores, options) {
250
304
  const taskIdAttribute = options.taskIdAttribute ?? "factoryTaskId";
@@ -263,9 +317,19 @@ function createFactoryHooks(stores, options) {
263
317
  attempt: attempt.data
264
318
  } : void 0;
265
319
  }
266
- async function recoverFailedAttempt(binding, failure) {
320
+ async function recoverBoundResult(task, ctx) {
321
+ const binding = boundExecution(ctx);
322
+ if (binding?.taskId !== task.id || binding.attempt !== task.attempt || task.execution?.provider !== "eve" || task.execution.sessionId !== ctx.session.id) return false;
323
+ return await options.recoverResult?.(task) ?? false;
324
+ }
325
+ async function recoverFailedAttempt(binding, failure, ctx) {
267
326
  const task = await stores.tasks.get(binding.taskId);
268
- if (task?.state !== "running" || task.attempt !== binding.attempt) return;
327
+ if (!task || task.attempt !== binding.attempt || options.recoverResult && task.execution && (task.execution.provider !== "eve" || task.execution.sessionId !== ctx.session.id)) return;
328
+ if (await recoverBoundResult(task, ctx)) {
329
+ await settleIfTerminal(task.id);
330
+ return;
331
+ }
332
+ if (task.state !== "running") return;
269
333
  const exhausted = task.attempt >= maxAttempts;
270
334
  await stores.tasks.transition(task.id, exhausted ? "failed" : "queued", {
271
335
  expectFrom: "running",
@@ -299,8 +363,8 @@ function createFactoryHooks(stores, options) {
299
363
  reason: "measured usage settled at turn end"
300
364
  });
301
365
  }
302
- async function escalateUnresolvedTurn(task) {
303
- await createAndPresentQuestion(stores, task, UNRESOLVED_TURN_QUESTION, options.presentQuestion).catch(() => void 0);
366
+ async function escalateUnresolvedTurn(task, completedSessionId) {
367
+ await createAndPresentQuestion(stores, task, UNRESOLVED_TURN_QUESTION, options.presentQuestion, completedSessionId).catch(() => void 0);
304
368
  }
305
369
  return { factory: defineHook({ events: {
306
370
  async "step.completed"(event, ctx) {
@@ -312,7 +376,7 @@ function createFactoryHooks(stores, options) {
312
376
  if ((await stores.tasks.get(taskId).catch(() => null))?.attempt !== binding.attempt) return;
313
377
  }
314
378
  await stores.tasks.recordUsage(taskId, { step: {
315
- turnId: event.data.turnId,
379
+ turnId: JSON.stringify([ctx.session.id, event.data.turnId]),
316
380
  stepIndex: event.data.stepIndex,
317
381
  ...usage
318
382
  } }).catch(() => void 0);
@@ -320,7 +384,7 @@ function createFactoryHooks(stores, options) {
320
384
  async "turn.failed"(event, ctx) {
321
385
  const binding = boundExecution(ctx);
322
386
  if (binding !== void 0) {
323
- await recoverFailedAttempt(binding, `provider turn failed (${event.data.code}): ${event.data.message}`).catch(() => void 0);
387
+ await recoverFailedAttempt(binding, `provider turn failed (${event.data.code}): ${event.data.message}`, ctx).catch(() => void 0);
324
388
  return;
325
389
  }
326
390
  const taskId = boundTaskId(ctx);
@@ -335,7 +399,7 @@ function createFactoryHooks(stores, options) {
335
399
  },
336
400
  async "session.failed"(event, ctx) {
337
401
  const binding = boundExecution(ctx);
338
- if (binding !== void 0) await recoverFailedAttempt(binding, `provider session failed (${event.data.code}): ${event.data.message}`).catch(() => void 0);
402
+ if (binding !== void 0) await recoverFailedAttempt(binding, `provider session failed (${event.data.code}): ${event.data.message}`, ctx).catch(() => void 0);
339
403
  await options.materializeTranscript?.(materializationInput(ctx)).catch(() => void 0);
340
404
  },
341
405
  async "session.waiting"(_event, ctx) {
@@ -366,7 +430,14 @@ function createFactoryHooks(stores, options) {
366
430
  if (taskId !== void 0) {
367
431
  const task = await stores.tasks.get(taskId).catch(() => null);
368
432
  if (binding !== void 0 && task?.attempt !== binding.attempt) return;
369
- if (task?.state === "running" && !task.waitingForMessages) {
433
+ if (binding && options.recoverResult && task?.execution && (task.execution.provider !== "eve" || task.execution.sessionId !== ctx.session.id)) return;
434
+ let recovered;
435
+ try {
436
+ recovered = task !== null && await recoverBoundResult(task, ctx);
437
+ } catch {
438
+ return;
439
+ }
440
+ if (!recovered && task?.state === "running" && !task.waitingForMessages) {
370
441
  const completion = await options.completeConversation?.(task).catch(() => false);
371
442
  if (completion) await stores.tasks.transition(taskId, "succeeded", {
372
443
  expectFrom: "running",
@@ -374,7 +445,7 @@ function createFactoryHooks(stores, options) {
374
445
  reason: "Conversation turn completed",
375
446
  output: completion.output
376
447
  }).catch(() => void 0);
377
- else await escalateUnresolvedTurn(task);
448
+ else await escalateUnresolvedTurn(task, binding && task.execution?.provider === "eve" && task.execution.sessionId === ctx.session.id ? ctx.session.id : void 0);
378
449
  }
379
450
  await settleIfTerminal(taskId).catch(() => void 0);
380
451
  }