@wardby/cli 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/.env.example +22 -3
  2. package/README.md +11 -9
  3. package/dist/cli-help.d.ts +1 -1
  4. package/dist/cli-help.js +1 -0
  5. package/dist/cli.js +68 -10
  6. package/dist/coding/base-commit.d.ts +6 -0
  7. package/dist/coding/base-commit.js +12 -0
  8. package/dist/coding/protocol.d.ts +17 -1
  9. package/dist/coding/protocol.js +17 -6
  10. package/dist/coding/provider.d.ts +6 -1
  11. package/dist/coding/provider.js +16 -9
  12. package/dist/config/providers.d.ts +25 -0
  13. package/dist/config/providers.js +71 -0
  14. package/dist/core/attribution.d.ts +101 -0
  15. package/dist/core/attribution.js +208 -0
  16. package/dist/core/budget-groups.d.ts +17 -10
  17. package/dist/core/budget-groups.js +15 -12
  18. package/dist/core/coding-queue.d.ts +3 -0
  19. package/dist/core/coding-queue.js +6 -2
  20. package/dist/core/coding-service-status.d.ts +11 -0
  21. package/dist/core/coding-service-status.js +17 -0
  22. package/dist/core/cost-report.d.ts +88 -0
  23. package/dist/core/cost-report.js +248 -0
  24. package/dist/core/dispatch.d.ts +42 -2
  25. package/dist/core/dispatch.js +144 -26
  26. package/dist/core/engine-native.js +17 -4
  27. package/dist/core/glob.d.ts +10 -0
  28. package/dist/core/glob.js +33 -0
  29. package/dist/core/host-events.d.ts +24 -1
  30. package/dist/core/host-events.js +341 -1
  31. package/dist/core/host-status.d.ts +19 -2
  32. package/dist/core/host-status.js +47 -30
  33. package/dist/core/issue-bridge.d.ts +60 -0
  34. package/dist/core/issue-bridge.js +189 -0
  35. package/dist/core/issue-dedupe.d.ts +70 -0
  36. package/dist/core/issue-dedupe.js +255 -0
  37. package/dist/core/issue-events.d.ts +42 -0
  38. package/dist/core/issue-events.js +155 -0
  39. package/dist/core/issue-status.d.ts +29 -0
  40. package/dist/core/issue-status.js +241 -0
  41. package/dist/core/issue-tracker-tools.d.ts +64 -0
  42. package/dist/core/issue-tracker-tools.js +850 -0
  43. package/dist/core/model-usage.d.ts +10 -0
  44. package/dist/core/model-usage.js +24 -0
  45. package/dist/core/reconciler.d.ts +8 -4
  46. package/dist/core/reconciler.js +15 -4
  47. package/dist/core/review-host-tools.js +10 -3
  48. package/dist/core/run-pricing.d.ts +61 -0
  49. package/dist/core/run-pricing.js +56 -0
  50. package/dist/core/runner.d.ts +5 -2
  51. package/dist/core/runner.js +178 -27
  52. package/dist/core/scheduler.d.ts +4 -1
  53. package/dist/core/scheduler.js +3 -2
  54. package/dist/core/self-defects.d.ts +80 -0
  55. package/dist/core/self-defects.js +180 -0
  56. package/dist/core/tool-names.js +3 -0
  57. package/dist/core/webhooks.d.ts +9 -1
  58. package/dist/core/webhooks.js +19 -1
  59. package/dist/env.js +6 -1
  60. package/dist/generated/prisma/browser.d.ts +66 -0
  61. package/dist/generated/prisma/client.d.ts +66 -0
  62. package/dist/generated/prisma/commonInputTypes.d.ts +122 -52
  63. package/dist/generated/prisma/enums.d.ts +7 -0
  64. package/dist/generated/prisma/enums.js +6 -0
  65. package/dist/generated/prisma/internal/class.d.ts +99 -0
  66. package/dist/generated/prisma/internal/class.js +4 -4
  67. package/dist/generated/prisma/internal/prismaNamespace.d.ts +826 -1
  68. package/dist/generated/prisma/internal/prismaNamespace.js +135 -2
  69. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +142 -0
  70. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +135 -2
  71. package/dist/generated/prisma/models/Agent.d.ts +389 -1
  72. package/dist/generated/prisma/models/AgentIssueProject.d.ts +1838 -0
  73. package/dist/generated/prisma/models/AgentIssueProject.js +1 -0
  74. package/dist/generated/prisma/models/AgentRepository.d.ts +1 -1
  75. package/dist/generated/prisma/models/AuthUser.d.ts +1 -1
  76. package/dist/generated/prisma/models/CodingProxySession.d.ts +73 -1
  77. package/dist/generated/prisma/models/CodingRun.d.ts +130 -1
  78. package/dist/generated/prisma/models/CodingRunServiceStatus.d.ts +1404 -0
  79. package/dist/generated/prisma/models/CodingRunServiceStatus.js +1 -0
  80. package/dist/generated/prisma/models/IssueFingerprint.d.ts +1183 -0
  81. package/dist/generated/prisma/models/IssueFingerprint.js +1 -0
  82. package/dist/generated/prisma/models/IssuePullRequest.d.ts +1255 -0
  83. package/dist/generated/prisma/models/IssuePullRequest.js +1 -0
  84. package/dist/generated/prisma/models/ModelCatalogEntry.d.ts +1322 -0
  85. package/dist/generated/prisma/models/ModelCatalogEntry.js +1 -0
  86. package/dist/generated/prisma/models/Run.d.ts +933 -1
  87. package/dist/generated/prisma/models/RunAttribution.d.ts +1259 -0
  88. package/dist/generated/prisma/models/RunAttribution.js +1 -0
  89. package/dist/generated/prisma/models/RunIssueStatus.d.ts +1199 -0
  90. package/dist/generated/prisma/models/RunIssueStatus.js +1 -0
  91. package/dist/generated/prisma/models/RunModelUsage.d.ts +1316 -0
  92. package/dist/generated/prisma/models/RunModelUsage.js +1 -0
  93. package/dist/generated/prisma/models/WorkItem.d.ts +1408 -0
  94. package/dist/generated/prisma/models/WorkItem.js +1 -0
  95. package/dist/generated/prisma/models.d.ts +9 -0
  96. package/dist/help-index.json +355 -16
  97. package/dist/import/neutral-schema.d.ts +16 -16
  98. package/dist/knowledge/check.d.ts +13 -0
  99. package/dist/knowledge/check.js +69 -0
  100. package/dist/knowledge/cli.d.ts +14 -0
  101. package/dist/knowledge/cli.js +67 -0
  102. package/dist/knowledge/concept.d.ts +54 -0
  103. package/dist/knowledge/concept.js +78 -0
  104. package/dist/knowledge/note.d.ts +11 -0
  105. package/dist/knowledge/note.js +39 -0
  106. package/dist/knowledge/relevance.d.ts +11 -0
  107. package/dist/knowledge/relevance.js +14 -0
  108. package/dist/knowledge/span-hash.d.ts +3 -0
  109. package/dist/knowledge/span-hash.js +16 -0
  110. package/dist/mcp/auth/access.d.ts +4 -2
  111. package/dist/mcp/auth/ownership.d.ts +9 -9
  112. package/dist/mcp/auth/resource-server.d.ts +3 -1
  113. package/dist/mcp/auth/resource-server.js +18 -3
  114. package/dist/mcp/auth/self-hosted/credentials.d.ts +3 -3
  115. package/dist/mcp/auth/self-hosted/session.d.ts +5 -5
  116. package/dist/mcp/context.d.ts +3 -0
  117. package/dist/mcp/host-events/deliveries.d.ts +9 -0
  118. package/dist/mcp/host-events/deliveries.js +17 -0
  119. package/dist/mcp/host-events/github-ingress.d.ts +4 -2
  120. package/dist/mcp/host-events/github-ingress.js +4 -13
  121. package/dist/mcp/host-events/jira-ingress.d.ts +29 -0
  122. package/dist/mcp/host-events/jira-ingress.js +92 -0
  123. package/dist/mcp/index.d.ts +2 -0
  124. package/dist/mcp/index.js +87 -9
  125. package/dist/mcp/server.js +5 -2
  126. package/dist/mcp/tools/agents.js +74 -3
  127. package/dist/mcp/tools/cost-report.d.ts +8 -0
  128. package/dist/mcp/tools/cost-report.js +60 -0
  129. package/dist/mcp/tools/issue-projects.d.ts +2 -0
  130. package/dist/mcp/tools/issue-projects.js +238 -0
  131. package/dist/mcp/tools/model-catalog.d.ts +22 -0
  132. package/dist/mcp/tools/model-catalog.js +423 -0
  133. package/dist/mcp/tools/repositories.js +2 -1
  134. package/dist/mcp/tools/tools.d.ts +2 -2
  135. package/dist/mcp/tools/trigger.js +33 -5
  136. package/dist/mcp/transport/streamable-http.d.ts +5 -0
  137. package/dist/mcp/transport/streamable-http.js +23 -1
  138. package/dist/mcp/webhooks/ingress.d.ts +2 -1
  139. package/dist/mcp/webhooks/ingress.js +9 -2
  140. package/dist/providers/auth/self-hosted.d.ts +8 -1
  141. package/dist/providers/auth/self-hosted.js +39 -2
  142. package/dist/providers/coding-proxy/memory-ledger.d.ts +1 -1
  143. package/dist/providers/coding-proxy/memory-ledger.js +10 -1
  144. package/dist/providers/coding-proxy/metering.d.ts +2 -1
  145. package/dist/providers/coding-proxy/metering.js +13 -2
  146. package/dist/providers/coding-proxy/prisma-ledger.js +59 -6
  147. package/dist/providers/coding-proxy/proxy.d.ts +12 -2
  148. package/dist/providers/coding-proxy/proxy.js +92 -30
  149. package/dist/providers/coding-proxy/types.d.ts +18 -1
  150. package/dist/providers/coding-proxy/types.js +12 -1
  151. package/dist/providers/engine/types.d.ts +19 -0
  152. package/dist/providers/executor/composition.js +9 -1
  153. package/dist/providers/executor/container.d.ts +30 -2
  154. package/dist/providers/executor/container.js +98 -17
  155. package/dist/providers/executor/dbos.d.ts +2 -0
  156. package/dist/providers/executor/dbos.js +7 -5
  157. package/dist/providers/executor/routing.d.ts +6 -0
  158. package/dist/providers/executor/routing.js +5 -0
  159. package/dist/providers/executor/types.d.ts +12 -0
  160. package/dist/providers/issue-tracker/adf.d.ts +31 -0
  161. package/dist/providers/issue-tracker/adf.js +181 -0
  162. package/dist/providers/issue-tracker/index.d.ts +5 -0
  163. package/dist/providers/issue-tracker/index.js +12 -0
  164. package/dist/providers/issue-tracker/jira-client.d.ts +41 -0
  165. package/dist/providers/issue-tracker/jira-client.js +151 -0
  166. package/dist/providers/issue-tracker/jira-events.d.ts +3 -0
  167. package/dist/providers/issue-tracker/jira-events.js +98 -0
  168. package/dist/providers/issue-tracker/jira.d.ts +116 -0
  169. package/dist/providers/issue-tracker/jira.js +502 -0
  170. package/dist/providers/issue-tracker/types.d.ts +269 -0
  171. package/dist/providers/issue-tracker/types.js +16 -0
  172. package/dist/providers/jobs/docker.d.ts +5 -1
  173. package/dist/providers/jobs/docker.js +61 -33
  174. package/dist/providers/jobs/kubernetes.d.ts +3 -0
  175. package/dist/providers/jobs/kubernetes.js +44 -4
  176. package/dist/providers/jobs/service-state.d.ts +22 -0
  177. package/dist/providers/jobs/service-state.js +17 -0
  178. package/dist/providers/llm/anthropic.d.ts +3 -3
  179. package/dist/providers/llm/anthropic.js +3 -9
  180. package/dist/providers/llm/bedrock.d.ts +3 -3
  181. package/dist/providers/llm/bedrock.js +3 -9
  182. package/dist/providers/llm/catalog-lookup.d.ts +10 -0
  183. package/dist/providers/llm/catalog-lookup.js +15 -0
  184. package/dist/providers/llm/catalog-shipped.d.ts +18 -0
  185. package/dist/providers/llm/catalog-shipped.js +197 -0
  186. package/dist/providers/llm/catalog-store.d.ts +58 -0
  187. package/dist/providers/llm/catalog-store.js +138 -0
  188. package/dist/providers/llm/catalog-types.d.ts +66 -0
  189. package/dist/providers/llm/catalog-types.js +64 -0
  190. package/dist/providers/llm/catalog.d.ts +61 -0
  191. package/dist/providers/llm/catalog.js +147 -0
  192. package/dist/providers/llm/claude-provider.d.ts +10 -14
  193. package/dist/providers/llm/claude-provider.js +11 -6
  194. package/dist/providers/llm/index.d.ts +9 -6
  195. package/dist/providers/llm/index.js +8 -5
  196. package/dist/providers/llm/openai.d.ts +14 -5
  197. package/dist/providers/llm/openai.js +24 -14
  198. package/dist/providers/llm/pricing-core.d.ts +5 -3
  199. package/dist/providers/llm/registration.js +8 -12
  200. package/dist/providers/llm/routing.d.ts +18 -17
  201. package/dist/providers/llm/routing.js +40 -24
  202. package/dist/providers/review-host/github-events.js +47 -1
  203. package/dist/providers/review-host/github.js +7 -6
  204. package/dist/providers/review-host/types.d.ts +25 -0
  205. package/dist/providers/vcs/git.js +2 -22
  206. package/dist/providers/vcs/github.d.ts +20 -0
  207. package/dist/providers/vcs/github.js +28 -2
  208. package/dist/providers/vcs/types.d.ts +6 -0
  209. package/dist/quickstart/index.d.ts +8 -0
  210. package/dist/quickstart/index.js +34 -34
  211. package/dist/serve.js +8 -2
  212. package/dist/viewer/api-schema.d.ts +2757 -0
  213. package/dist/viewer/api-schema.js +165 -0
  214. package/dist/viewer/build-schemas.d.ts +2 -0
  215. package/dist/viewer/build-schemas.js +18 -0
  216. package/dist/viewer/event-bus.d.ts +38 -0
  217. package/dist/viewer/event-bus.js +232 -0
  218. package/dist/viewer/graph.d.ts +40 -0
  219. package/dist/viewer/graph.js +243 -0
  220. package/dist/viewer/http.d.ts +30 -0
  221. package/dist/viewer/http.js +133 -0
  222. package/dist/viewer/run-detail.d.ts +4 -0
  223. package/dist/viewer/run-detail.js +61 -0
  224. package/dist/wardby-bin.js +5 -0
  225. package/docs/README.md +10 -0
  226. package/docs/agent-recipes.md +383 -0
  227. package/docs/code-review-agents.md +29 -2
  228. package/docs/coding-agent-setup.md +3 -0
  229. package/docs/coding-worker-isolation.md +39 -5
  230. package/docs/getting-started-gke.md +28 -11
  231. package/docs/getting-started-identity-provider.md +49 -38
  232. package/docs/getting-started.md +14 -0
  233. package/docs/jira-agents.md +649 -0
  234. package/docs/knowledge.md +387 -0
  235. package/docs/models.md +221 -0
  236. package/docs/security-deployment.md +19 -9
  237. package/docs/viewer-api.md +142 -0
  238. package/help/admin-viewer.md +39 -0
  239. package/help/agent-recipes.md +173 -0
  240. package/help/architecture-agent.md +189 -0
  241. package/help/builder-agent.md +80 -0
  242. package/help/code-review-agents.md +6 -0
  243. package/help/cost-attribution.md +67 -0
  244. package/help/creating-agents.md +22 -0
  245. package/help/deploy-gke.md +6 -0
  246. package/help/errors/model-unavailable.md +63 -0
  247. package/help/getting-started.md +1 -0
  248. package/help/github.md +18 -0
  249. package/help/identity-and-access.md +8 -3
  250. package/help/jira.md +135 -0
  251. package/help/knowledge.md +47 -0
  252. package/help/models.md +90 -0
  253. package/help/operating-agents.md +7 -1
  254. package/help/troubleshooting/budgets.md +6 -0
  255. package/package.json +5 -2
  256. package/prisma/migrations/20260930000000_jira_issue_projects/migration.sql +34 -0
  257. package/prisma/migrations/20261001000000_jira_phase2_allowlists/migration.sql +3 -0
  258. package/prisma/migrations/20261001010000_jira_link_types_allowlist/migration.sql +2 -0
  259. package/prisma/migrations/20261002000000_jira_coding_bridge/migration.sql +28 -0
  260. package/prisma/migrations/20261002010000_jira_issue_creation/migration.sql +25 -0
  261. package/prisma/migrations/20261003000000_issue_cost_attribution/migration.sql +56 -0
  262. package/prisma/migrations/20261003010000_coding_run_service_status/migration.sql +23 -0
  263. package/prisma/migrations/20261003020000_viewer_notify/migration.sql +54 -0
  264. package/prisma/migrations/20261003030000_viewer_notify_fixes/migration.sql +47 -0
  265. package/prisma/migrations/20261003040000_viewer_indexes/migration.sql +12 -0
  266. package/prisma/migrations/20261004000000_model_catalog/migration.sql +26 -0
  267. package/prisma/schema.prisma +258 -2
  268. package/dist/mcp/tools/models.d.ts +0 -8
  269. package/dist/mcp/tools/models.js +0 -15
  270. package/dist/providers/llm/pricing-anthropic.d.ts +0 -14
  271. package/dist/providers/llm/pricing-anthropic.js +0 -48
  272. package/dist/providers/llm/pricing-bedrock-claude.d.ts +0 -20
  273. package/dist/providers/llm/pricing-bedrock-claude.js +0 -46
  274. package/dist/providers/llm/pricing.d.ts +0 -30
  275. package/dist/providers/llm/pricing.js +0 -74
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Cost by issue / parent / scope / agent / model / run over a time window
3
+ * (docs/private/2026-10-01-issue-cost-attribution-design.md §7). Rows count
4
+ * attributed runs only; unattributed spend is reported separately so the gap
5
+ * is visible. Money is summed as NUMERIC in SQL and returned as strings; tokens
6
+ * are always by priced kind, never one total.
7
+ */
8
+ import { Prisma } from "#prisma";
9
+ const GROUP_BYS = ["issue", "parent", "scope", "agent", "model", "run"];
10
+ const DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
11
+ const DEFAULT_LIMIT = 25;
12
+ const MAX_LIMIT = 200;
13
+ export class CostReportInputError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "CostReportInputError";
17
+ }
18
+ }
19
+ function optionalString(args, name) {
20
+ const v = args[name];
21
+ if (v === undefined)
22
+ return undefined;
23
+ if (typeof v !== "string" || v.length === 0 || v.length > 256) {
24
+ throw new CostReportInputError(`${name} must be a non-empty string.`);
25
+ }
26
+ return v;
27
+ }
28
+ function optionalDate(args, name) {
29
+ const v = args[name];
30
+ if (v === undefined)
31
+ return undefined;
32
+ const d = typeof v === "string" ? new Date(v) : new Date(NaN);
33
+ if (Number.isNaN(d.getTime()))
34
+ throw new CostReportInputError(`${name} must be an ISO date.`);
35
+ return d;
36
+ }
37
+ export function parseCostReportQuery(raw, now = new Date()) {
38
+ const args = raw && typeof raw === "object" ? raw : {};
39
+ const groupBy = (args.groupBy ?? "issue");
40
+ if (!GROUP_BYS.includes(groupBy)) {
41
+ throw new CostReportInputError(`groupBy must be one of: ${GROUP_BYS.join(", ")}.`);
42
+ }
43
+ const to = optionalDate(args, "to") ?? now;
44
+ const from = optionalDate(args, "from") ?? new Date(to.getTime() - DEFAULT_WINDOW_MS);
45
+ if (from.getTime() >= to.getTime())
46
+ throw new CostReportInputError("from must be before to.");
47
+ let limit = DEFAULT_LIMIT;
48
+ if (args.limit !== undefined) {
49
+ if (typeof args.limit !== "number" || !Number.isInteger(args.limit) || args.limit < 1) {
50
+ throw new CostReportInputError("limit must be a positive integer.");
51
+ }
52
+ limit = Math.min(args.limit, MAX_LIMIT);
53
+ }
54
+ const q = { groupBy, from, to, limit };
55
+ for (const name of ["provider", "scopeKey", "parentKey", "issueKey", "agentId"]) {
56
+ const v = optionalString(args, name);
57
+ if (v !== undefined)
58
+ q[name] = v;
59
+ }
60
+ return q;
61
+ }
62
+ const NO_PARENT = "(no parent)";
63
+ /** NUMERIC text → canonical decimal string without trailing zeros ("3.100000" → "3.1", null → "0"). */
64
+ function usd(v) {
65
+ if (v == null)
66
+ return "0";
67
+ return v.includes(".") ? v.replace(/0+$/, "").replace(/\.$/, "") : v;
68
+ }
69
+ const kinds = (r) => ({
70
+ freshInput: Number(r?.fresh ?? 0),
71
+ cachedInput: Number(r?.cached ?? 0),
72
+ cacheWrite: Number(r?.write ?? 0),
73
+ output: Number(r?.out ?? 0),
74
+ });
75
+ /** The run filter every query shares: window, visibility, and the agent filter. Only `r` must be in scope. */
76
+ function runFilter(q, vis) {
77
+ const parts = [Prisma.sql `r."startedAt" >= ${q.from} AND r."startedAt" < ${q.to}`];
78
+ if (vis) {
79
+ parts.push(vis.ownedAgentIds.length > 0
80
+ ? Prisma.sql `(r."agentId" IN (${Prisma.join(vis.ownedAgentIds)}) OR r."triggeredById" = ${vis.principalId})`
81
+ : Prisma.sql `r."triggeredById" = ${vis.principalId}`);
82
+ }
83
+ if (q.agentId)
84
+ parts.push(Prisma.sql `r."agentId" = ${q.agentId}`);
85
+ return Prisma.join(parts, " AND ");
86
+ }
87
+ /** The work-item filters; `a` (RunAttribution) and `w` (WorkItem) must be joined. */
88
+ function attributionFilter(q) {
89
+ const parts = [Prisma.sql `TRUE`];
90
+ if (q.provider)
91
+ parts.push(Prisma.sql `w."provider" = ${q.provider}`);
92
+ if (q.scopeKey)
93
+ parts.push(Prisma.sql `w."scopeKey" = ${q.scopeKey}`);
94
+ if (q.parentKey)
95
+ parts.push(Prisma.sql `a."parentKeyAtRun" = ${q.parentKey}`);
96
+ if (q.issueKey)
97
+ parts.push(Prisma.sql `w."key" = ${q.issueKey}`);
98
+ return Prisma.join(parts, " AND ");
99
+ }
100
+ /** The group key for each groupBy, over Run r / RunAttribution a / WorkItem w (and RunModelUsage u for "model"). */
101
+ const GROUP_KEY = {
102
+ issue: Prisma.sql `w."key"`,
103
+ parent: Prisma.sql `COALESCE(a."parentKeyAtRun", '(no parent)')`,
104
+ scope: Prisma.sql `w."scopeKey"`,
105
+ agent: Prisma.sql `r."agentId"`,
106
+ model: Prisma.sql `u."model"`,
107
+ run: Prisma.sql `r."id"`,
108
+ };
109
+ const ATTRIBUTED = Prisma.sql `"Run" r JOIN "RunAttribution" a ON a."runId" = r."id" JOIN "WorkItem" w ON w."id" = a."workItemId"`;
110
+ const USAGE_ATTRIBUTED = Prisma.sql `"RunModelUsage" u JOIN ${ATTRIBUTED} ON r."id" = u."runId"`;
111
+ /**
112
+ * How long one report may take. Prisma's interactive-transaction default (5 s)
113
+ * is too short for a wide window over a large Run table; the same limit is set
114
+ * as the transaction's statement_timeout so Postgres stops the work too, not
115
+ * only the client.
116
+ */
117
+ export const COST_REPORT_TIMEOUT_MS = 30_000;
118
+ /**
119
+ * Every query runs in one read-only REPEATABLE READ transaction, so the rows,
120
+ * the totals and the unattributed figure all come from the same snapshot even
121
+ * while runs are finishing.
122
+ */
123
+ export async function costReport(db, q, visibility) {
124
+ return db.$transaction(async (tx) => {
125
+ await tx.$executeRaw `SET TRANSACTION READ ONLY`;
126
+ // SET takes no bind parameters; the value is a constant.
127
+ await tx.$executeRawUnsafe(`SET LOCAL statement_timeout = '${COST_REPORT_TIMEOUT_MS}ms'`);
128
+ return reportIn(tx, q, visibility);
129
+ }, { isolationLevel: "RepeatableRead", timeout: COST_REPORT_TIMEOUT_MS });
130
+ }
131
+ async function reportIn(db, q, visibility) {
132
+ const where = Prisma.sql `${runFilter(q, visibility)} AND ${attributionFilter(q)}`;
133
+ const key = GROUP_KEY[q.groupBy];
134
+ // "model" groups usage rows, so its money comes from RunModelUsage; every other grouping sums Run.costUsd once per run.
135
+ const byModel = q.groupBy === "model";
136
+ const money = byModel ? Prisma.sql `u."costUsd"` : Prisma.sql `r."costUsd"`;
137
+ const source = byModel ? USAGE_ATTRIBUTED : ATTRIBUTED;
138
+ const groups = await db.$queryRaw `
139
+ SELECT ${key} AS key,
140
+ MIN(w."provider") AS provider,
141
+ MIN(w."parentKind") AS parent_kind,
142
+ COUNT(DISTINCT r."id") AS runs,
143
+ COUNT(DISTINCT r."id") FILTER (WHERE r."status" IN ('pending', 'running')) AS in_progress,
144
+ SUM(${money})::text AS usd,
145
+ SUM(${money}) FILTER (WHERE a."source" = 'issue_event')::text AS src_issue_event,
146
+ SUM(${money}) FILTER (WHERE a."source" = 'linked_pr')::text AS src_linked_pr,
147
+ SUM(${money}) FILTER (WHERE a."source" = 'explicit')::text AS src_explicit,
148
+ SUM(${money}) FILTER (WHERE a."source" = 'inherited')::text AS src_inherited,
149
+ MIN(r."startedAt") AS first_at,
150
+ MAX(r."startedAt") AS last_at
151
+ FROM ${source}
152
+ WHERE ${where}
153
+ GROUP BY ${key}
154
+ ORDER BY SUM(${money}) DESC NULLS LAST, ${key}
155
+ LIMIT ${q.limit + 1}`;
156
+ const truncated = groups.length > q.limit;
157
+ const page = groups.slice(0, q.limit);
158
+ // Per-group, per-model tokens and money for just this page's groups.
159
+ const usage = page.length
160
+ ? await db.$queryRaw `
161
+ SELECT ${key} AS key, u."model" AS model, SUM(u."costUsd")::text AS usd,
162
+ SUM(u."freshInputTokens") AS fresh, SUM(u."cachedInputTokens") AS cached,
163
+ SUM(u."cacheWriteTokens") AS write, SUM(u."outputTokens") AS out
164
+ FROM ${USAGE_ATTRIBUTED}
165
+ WHERE ${where} AND ${key} IN (${Prisma.join(page.map((g) => g.key))})
166
+ GROUP BY ${key}, u."model"
167
+ ORDER BY SUM(u."costUsd") DESC, u."model"`
168
+ : [];
169
+ const titles = await groupTitles(db, q.groupBy, page);
170
+ const rows = page.map((g) => {
171
+ const models = usage
172
+ .filter((u) => u.key === g.key)
173
+ .map((m) => ({ model: m.model, costUsd: usd(m.usd), tokens: kinds(m) }));
174
+ const tokens = models.reduce((t, m) => ({
175
+ freshInput: t.freshInput + m.tokens.freshInput,
176
+ cachedInput: t.cachedInput + m.tokens.cachedInput,
177
+ cacheWrite: t.cacheWrite + m.tokens.cacheWrite,
178
+ output: t.output + m.tokens.output,
179
+ }), { freshInput: 0, cachedInput: 0, cacheWrite: 0, output: 0 });
180
+ return {
181
+ key: g.key,
182
+ ...titles(g),
183
+ provider: g.provider,
184
+ runs: Number(g.runs),
185
+ inProgressRuns: Number(g.in_progress),
186
+ costUsd: usd(g.usd),
187
+ tokens,
188
+ byModel: models,
189
+ bySource: {
190
+ issue_event: usd(g.src_issue_event),
191
+ linked_pr: usd(g.src_linked_pr),
192
+ explicit: usd(g.src_explicit),
193
+ inherited: usd(g.src_inherited),
194
+ },
195
+ firstRunAt: g.first_at.toISOString(),
196
+ lastRunAt: g.last_at.toISOString(),
197
+ };
198
+ });
199
+ const [totals] = await db.$queryRaw `
200
+ SELECT COUNT(*) AS runs, SUM(r."costUsd")::text AS usd FROM ${ATTRIBUTED} WHERE ${where}`;
201
+ const [totalTokens] = await db.$queryRaw `
202
+ SELECT SUM(u."freshInputTokens") AS fresh, SUM(u."cachedInputTokens") AS cached,
203
+ SUM(u."cacheWriteTokens") AS write, SUM(u."outputTokens") AS out
204
+ FROM ${USAGE_ATTRIBUTED} WHERE ${where}`;
205
+ const [unattributed] = await db.$queryRaw `
206
+ SELECT COUNT(*) AS runs, SUM(r."costUsd")::text AS usd
207
+ FROM "Run" r
208
+ WHERE ${runFilter(q, visibility)}
209
+ AND NOT EXISTS (SELECT 1 FROM "RunAttribution" ra WHERE ra."runId" = r."id")`;
210
+ return {
211
+ currency: "USD",
212
+ groupBy: q.groupBy,
213
+ from: q.from.toISOString(),
214
+ to: q.to.toISOString(),
215
+ rows,
216
+ truncated,
217
+ totals: { runs: Number(totals?.runs ?? 0), costUsd: usd(totals?.usd), tokens: kinds(totalTokens) },
218
+ unattributed: { runs: Number(unattributed?.runs ?? 0), costUsd: usd(unattributed?.usd) },
219
+ };
220
+ }
221
+ /** Titles and kinds per group: current WorkItem names for issue/parent, agent names for agent; none otherwise. */
222
+ async function groupTitles(db, groupBy, page) {
223
+ const none = () => ({ title: null, kind: null });
224
+ if (page.length === 0)
225
+ return none;
226
+ if (groupBy === "issue" || groupBy === "parent") {
227
+ const keys = page.map((g) => g.key).filter((k) => k !== NO_PARENT);
228
+ if (keys.length === 0)
229
+ return none;
230
+ const items = await db.$queryRaw `
231
+ SELECT "provider", "key", "title", "type" FROM "WorkItem" WHERE "key" IN (${Prisma.join(keys)})`;
232
+ const byKey = new Map(items.map((i) => [`${i.provider}\u0000${i.key}`, i]));
233
+ return (g) => {
234
+ if (groupBy === "parent" && g.key === NO_PARENT)
235
+ return { title: null, kind: null };
236
+ const i = byKey.get(`${g.provider}\u0000${g.key}`);
237
+ // A parent's kind lives on its children (WorkItem.parentKind), selected in the group query.
238
+ return { title: i?.title ?? null, kind: groupBy === "parent" ? g.parent_kind : (i?.type ?? null) };
239
+ };
240
+ }
241
+ if (groupBy === "agent") {
242
+ const agents = await db.$queryRaw `
243
+ SELECT "id", "name" FROM "Agent" WHERE "id" IN (${Prisma.join(page.map((g) => g.key))})`;
244
+ const byId = new Map(agents.map((a) => [a.id, a.name]));
245
+ return (g) => ({ title: byId.get(g.key) ?? null, kind: "agent" });
246
+ }
247
+ return none;
248
+ }
@@ -1,8 +1,10 @@
1
1
  import type { Prisma, PrismaClient, Run, RunTrigger, Task } from "#prisma";
2
2
  import type { Executor } from "../providers/executor/types.js";
3
+ import { type AttributionIntent } from "./attribution.js";
3
4
  import { type BudgetConstraint } from "./budget-groups.js";
5
+ import { type SelfDefectSink } from "./self-defects.js";
4
6
  export type DispatchDb = Pick<PrismaClient, "agent" | "run" | "runHostCheck" | "codingRun" | "task" | "webhook" | "budgetGroup" | "$transaction" | "$queryRaw">;
5
- export type DispatchTx = Pick<Prisma.TransactionClient, "agent" | "run" | "runHostCheck" | "runHostStatus" | "codingRun" | "codingService" | "task" | "webhook" | "budgetGroup" | "resourceGrant" | "$queryRaw" | "$executeRawUnsafe">;
7
+ export type DispatchTx = Pick<Prisma.TransactionClient, "agent" | "run" | "runHostCheck" | "runHostStatus" | "runIssueStatus" | "codingRun" | "codingService" | "task" | "webhook" | "budgetGroup" | "resourceGrant" | "workItem" | "runAttribution" | "$queryRaw" | "$executeRawUnsafe">;
6
8
  type DispatchAgent = Prisma.AgentGetPayload<{
7
9
  include: {
8
10
  codingProfile: true;
@@ -39,6 +41,8 @@ export interface DispatchRunOptions {
39
41
  * can observe them (e.g. RunHostCheck, see core/host-events.ts).
40
42
  */
41
43
  afterPersist?: (tx: DispatchTx, run: Run) => Promise<void>;
44
+ /** Files a self-defect when the executor fails to start the run and this marks it failed. Optional. */
45
+ selfDefects?: SelfDefectSink;
42
46
  /**
43
47
  * Who the run is visible to besides the agent owner (Run.triggeredById,
44
48
  * resource-sharing grants spec §3.5): the trigger_agent caller, a
@@ -48,6 +52,13 @@ export interface DispatchRunOptions {
48
52
  triggeredById?: string | null;
49
53
  /** Sub-agent dispatch (see AgentSubAgent): links this run into a run tree. */
50
54
  parentRunId?: string;
55
+ /**
56
+ * Cost attribution for a run that starts a new attribution (issue event,
57
+ * linked PR, explicit key), resolved before the call (attribution.ts
58
+ * resolveWorkItem). Ignored when the parent run or the continued coding run
59
+ * is attributed: inheritance wins.
60
+ */
61
+ attribution?: AttributionIntent;
51
62
  grantedParentMemoryKeys?: string[];
52
63
  /**
53
64
  * Per-run task text for a NATIVE agent (appended to its systemPrompt at
@@ -88,6 +99,10 @@ export interface DispatchRunResult {
88
99
  * (Prisma 7) -- Prisma 6's engine put 40001 at meta.code, still accepted;
89
100
  * - at COMMIT (e.g. SSI write skew): an unwrapped DriverAdapterError, no
90
101
  * code or meta, with the SQLSTATE at cause.originalCode.
102
+ * Also a unique violation (P2002) on WorkItem: concurrent first dispatches
103
+ * attributed to one new issue each insert its WorkItem, and Postgres reports
104
+ * the loser as 23505 rather than 40001. The retry finds the committed row.
105
+ * Unique violations on other models are real errors and are not retried.
91
106
  *
92
107
  * @internal Exported only for the real-PostgreSQL tests.
93
108
  */
@@ -100,7 +115,7 @@ export declare function isSerializationConflict(err: unknown): boolean;
100
115
  * the conditional `updateMany` makes the second call match zero rows rather
101
116
  * than overwrite whatever landed in between.
102
117
  */
103
- export declare function markRunFailedFromExecutorError(db: Pick<DispatchDb, "run">, runId: string, err: unknown): Promise<void>;
118
+ export declare function markRunFailedFromExecutorError(db: Pick<DispatchDb, "run">, runId: string, err: unknown, selfDefects?: SelfDefectSink): Promise<void>;
104
119
  /**
105
120
  * The `Run.error` of a coding run refused at dispatch because a budget
106
121
  * constraint has nothing left: `budget_group_exhausted:<period>` or
@@ -114,6 +129,31 @@ export declare function budgetExhaustedError(constraint: BudgetConstraint): stri
114
129
  * @internal Exported only for the real-PostgreSQL tests.
115
130
  */
116
131
  export declare const PERSIST_ATTEMPTS = 8;
132
+ export type ContinuationRefusal = "unknown_run" | "other_repository" | "no_pull_request";
133
+ /** A continuation refused because of the run it names. Its message is fixed text, safe to show the model. */
134
+ export declare class ContinuationRefusedError extends Error {
135
+ readonly reason: ContinuationRefusal;
136
+ constructor(reason: ContinuationRefusal);
137
+ }
138
+ export type ContinuationCheck = {
139
+ ok: true;
140
+ root: {
141
+ runId: string;
142
+ baseRef: string;
143
+ headRef: string;
144
+ pullRequestNumber: number;
145
+ };
146
+ } | {
147
+ ok: false;
148
+ reason: ContinuationRefusal;
149
+ };
150
+ /**
151
+ * Whether this deployment can continue coding run `runId` in `repository`: the
152
+ * run (or its root) is in this database, in that repository, and opened a pull
153
+ * request. A run id read from a pull request can name a run that another
154
+ * deployment sharing the same App made, so check before relying on one.
155
+ */
156
+ export declare function checkContinuation(reader: Pick<DispatchTx, "codingRun">, runId: string, repository: string): Promise<ContinuationCheck>;
117
157
  /**
118
158
  * Persists every detached run input in one transaction, then invokes the
119
159
  * executor only after commit. A null result means the caller's transactional
@@ -1,12 +1,18 @@
1
1
  import { CODING_PROTOCOL_VERSION, CodingTaskInputSchema, normalizeGitHubRepository, normalizeGitRef, publicCodingRunResult, composeCodingTask, } from "../coding/protocol.js";
2
- import { assertCodingProviderModel } from "../coding/provider.js";
2
+ import { assertCodingProvider, assertCodingProviderModel } from "../coding/provider.js";
3
+ import { ModelUnavailableError, entryOf } from "../providers/llm/catalog-types.js";
3
4
  import { parseAllowedServiceNames, workerServices } from "../coding/services/catalog.js";
4
5
  import { MAX_SERVICE_DECLARATION_BYTES, ServiceDeclarationError, parseServiceDeclaration, } from "../coding/services/declaration.js";
5
6
  import { servicesInstructionNote } from "../coding/services/note.js";
6
7
  import { resolveRunServices } from "../coding/services/resolve.js";
7
8
  import { DECLARATION_UNAVAILABLE_SENTENCE, LAUNCHER_UNSUPPORTED_SENTENCE, invalidDeclarationSentence, serviceRefusal, } from "../coding/services/wording.js";
9
+ import { attributeRun } from "./attribution.js";
8
10
  import { effectiveBudgetForRun, MIN_RESERVATION_USD } from "./budget-groups.js";
11
+ import { DEFAULT_KNOWLEDGE_BUNDLE_PATH } from "../knowledge/concept.js";
12
+ import { KNOWLEDGE_INDEX_READ_MAX_BYTES, knowledgeSection } from "../knowledge/note.js";
9
13
  import { logger } from "./logger.js";
14
+ import { CodingModelProviderMismatchError, resolveCodingEntry } from "./run-pricing.js";
15
+ import { fileSelfDefectForRun } from "./self-defects.js";
10
16
  const dispatchLog = logger.child({ module: "dispatch" });
11
17
  /** SQLSTATEs worth retrying the persist transaction for: serialization failure, deadlock. */
12
18
  const RETRYABLE_SQLSTATES = new Set(["40001", "40P01"]);
@@ -21,6 +27,10 @@ const RETRYABLE_SQLSTATES = new Set(["40001", "40P01"]);
21
27
  * (Prisma 7) -- Prisma 6's engine put 40001 at meta.code, still accepted;
22
28
  * - at COMMIT (e.g. SSI write skew): an unwrapped DriverAdapterError, no
23
29
  * code or meta, with the SQLSTATE at cause.originalCode.
30
+ * Also a unique violation (P2002) on WorkItem: concurrent first dispatches
31
+ * attributed to one new issue each insert its WorkItem, and Postgres reports
32
+ * the loser as 23505 rather than 40001. The retry finds the committed row.
33
+ * Unique violations on other models are real errors and are not retried.
24
34
  *
25
35
  * @internal Exported only for the real-PostgreSQL tests.
26
36
  */
@@ -30,6 +40,8 @@ export function isSerializationConflict(err) {
30
40
  const candidate = err;
31
41
  if (candidate.code === "P2034")
32
42
  return true;
43
+ if (candidate.code === "P2002")
44
+ return candidate.meta?.modelName === "WorkItem";
33
45
  if (candidate.code === "P2010") {
34
46
  const sqlState = candidate.meta?.driverAdapterError?.cause?.originalCode;
35
47
  return (typeof sqlState === "string" && RETRYABLE_SQLSTATES.has(sqlState)) || candidate.meta?.code === "40001";
@@ -48,8 +60,8 @@ export function isSerializationConflict(err) {
48
60
  * the conditional `updateMany` makes the second call match zero rows rather
49
61
  * than overwrite whatever landed in between.
50
62
  */
51
- export async function markRunFailedFromExecutorError(db, runId, err) {
52
- await db.run.updateMany({
63
+ export async function markRunFailedFromExecutorError(db, runId, err, selfDefects) {
64
+ const updated = await db.run.updateMany({
53
65
  where: { id: runId, status: { in: ["pending", "running"] } },
54
66
  data: {
55
67
  status: "failed",
@@ -57,6 +69,9 @@ export async function markRunFailedFromExecutorError(db, runId, err) {
57
69
  finishedAt: new Date(),
58
70
  },
59
71
  });
72
+ // Only the call that made the row failed files (the duplicate call matches zero rows); bounded, never throws.
73
+ if (updated.count > 0)
74
+ await fileSelfDefectForRun(selfDefects, runId);
60
75
  }
61
76
  /**
62
77
  * The `Run.error` of a coding run refused at dispatch because a budget
@@ -111,32 +126,67 @@ function retryDelayMs(attempt) {
111
126
  const ceiling = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
112
127
  return Math.floor(ceiling / 2 + Math.random() * (ceiling / 2));
113
128
  }
129
+ const CONTINUATION_REFUSALS = {
130
+ unknown_run: "Cannot continue an unknown coding run.",
131
+ other_repository: "Cannot continue a coding run from a different repository.",
132
+ no_pull_request: "Cannot continue a coding run that never opened a pull request.",
133
+ };
134
+ /** A continuation refused because of the run it names. Its message is fixed text, safe to show the model. */
135
+ export class ContinuationRefusedError extends Error {
136
+ reason;
137
+ constructor(reason) {
138
+ super(CONTINUATION_REFUSALS[reason]);
139
+ this.reason = reason;
140
+ this.name = "ContinuationRefusedError";
141
+ }
142
+ }
114
143
  /**
115
- * Resolves a coding run's branch: an override, else a continuation root's
116
- * (verified to be in the same repository and to have opened a pull request),
117
- * else the profile's base.
144
+ * Whether this deployment can continue coding run `runId` in `repository`: the
145
+ * run (or its root) is in this database, in that repository, and opened a pull
146
+ * request. A run id read from a pull request can name a run that another
147
+ * deployment sharing the same App made, so check before relying on one.
118
148
  */
119
- async function resolveCodingBranch(reader, options, profile) {
120
- if (options.continuesCodingRunId === undefined)
121
- return { baseRef: options.codingBaseRef ?? profile.baseRef };
122
- if (options.codingBaseRef !== undefined) {
123
- throw new Error("continuesCodingRunId cannot be combined with codingBaseRef.");
124
- }
125
- const candidate = await reader.codingRun.findUnique({ where: { runId: options.continuesCodingRunId } });
149
+ export async function checkContinuation(reader, runId, repository) {
150
+ const candidate = await reader.codingRun.findUnique({ where: { runId } });
126
151
  if (!candidate)
127
- throw new Error("Cannot continue an unknown coding run.");
152
+ return { ok: false, reason: "unknown_run" };
128
153
  const root = candidate.rootCodingRunId
129
154
  ? await reader.codingRun.findUnique({ where: { runId: candidate.rootCodingRunId } })
130
155
  : candidate;
131
156
  if (!root)
132
- throw new Error("Cannot continue an unknown coding run.");
133
- if (normalizeGitHubRepository(root.repository) !== normalizeGitHubRepository(profile.repository)) {
134
- throw new Error("Cannot continue a coding run from a different repository.");
157
+ return { ok: false, reason: "unknown_run" };
158
+ if (normalizeGitHubRepository(root.repository) !== normalizeGitHubRepository(repository)) {
159
+ return { ok: false, reason: "other_repository" };
135
160
  }
136
161
  const rootResult = publicCodingRunResult(root.result);
137
- if (rootResult?.outcome !== "pull_request_opened" && rootResult?.outcome !== "pull_request_updated") {
138
- throw new Error("Cannot continue a coding run that never opened a pull request.");
162
+ if ((rootResult?.outcome !== "pull_request_opened" && rootResult?.outcome !== "pull_request_updated") ||
163
+ rootResult.pullRequestNumber === undefined) {
164
+ return { ok: false, reason: "no_pull_request" };
139
165
  }
166
+ return {
167
+ ok: true,
168
+ root: {
169
+ runId: root.runId,
170
+ baseRef: root.baseRef,
171
+ headRef: root.headRef,
172
+ pullRequestNumber: rootResult.pullRequestNumber,
173
+ },
174
+ };
175
+ }
176
+ /**
177
+ * Resolves a coding run's branch: an override, else a continuation root's
178
+ * (see checkContinuation), else the profile's base.
179
+ */
180
+ async function resolveCodingBranch(reader, options, profile) {
181
+ if (options.continuesCodingRunId === undefined)
182
+ return { baseRef: options.codingBaseRef ?? profile.baseRef };
183
+ if (options.codingBaseRef !== undefined) {
184
+ throw new Error("continuesCodingRunId cannot be combined with codingBaseRef.");
185
+ }
186
+ const check = await checkContinuation(reader, options.continuesCodingRunId, profile.repository);
187
+ if (!check.ok)
188
+ throw new ContinuationRefusedError(check.reason);
189
+ const { root } = check;
140
190
  return {
141
191
  baseRef: root.baseRef,
142
192
  headRef: root.headRef,
@@ -150,6 +200,35 @@ const UNREADABLE_DECLARATION = {
150
200
  github_file_not_a_file: "it is not a file",
151
201
  github_file_not_utf8: "it is not UTF-8 text",
152
202
  };
203
+ /**
204
+ * The knowledge note's input (docs/knowledge.md): the repository's bundle
205
+ * index at the run's base, read before the transaction like the services
206
+ * declaration. Any failure means no note, never a failed dispatch.
207
+ */
208
+ async function readKnowledgeIndex(options, profile, resolvedBranch) {
209
+ const executor = options.executor;
210
+ if (!executor.readCodingRepositoryFile)
211
+ return undefined;
212
+ try {
213
+ // Resolved here, inside the try: a branch problem is the transaction's to report, not the note's.
214
+ const branch = resolvedBranch ?? (await resolveCodingBranch(options.db, options, profile));
215
+ const indexText = await executor.readCodingRepositoryFile({
216
+ repository: profile.repository,
217
+ baseRef: normalizeGitRef(branch.baseRef),
218
+ path: `${DEFAULT_KNOWLEDGE_BUNDLE_PATH}/index.md`,
219
+ maxBytes: KNOWLEDGE_INDEX_READ_MAX_BYTES,
220
+ });
221
+ return indexText ? { bundlePath: DEFAULT_KNOWLEDGE_BUNDLE_PATH, indexText } : undefined;
222
+ }
223
+ catch (err) {
224
+ if (err instanceof Error && err.message in UNREADABLE_DECLARATION) {
225
+ dispatchLog.info({ repository: profile.repository, reason: err.message }, "knowledge index unreadable; dispatching without it");
226
+ return undefined;
227
+ }
228
+ dispatchLog.warn({ err, repository: profile.repository }, "knowledge index read failed; dispatching without it");
229
+ return undefined;
230
+ }
231
+ }
153
232
  /**
154
233
  * Coding-run services (docs/coding-services.md): resolves the run's branch and
155
234
  * reads the repository's declaration from its base, then parses it. A network
@@ -245,6 +324,10 @@ export async function dispatchRun(options) {
245
324
  parseAllowedServiceNames(preview.codingProfile.services).length > 0
246
325
  ? await readServiceDeclaration(options, preview.codingProfile)
247
326
  : undefined;
327
+ // The knowledge note's input: the repository's bundle index at the run's base (a network call).
328
+ const knowledgeIndex = preview?.kind === "coding" && preview.codingProfile && options.executor.readCodingRepositoryFile
329
+ ? await readKnowledgeIndex(options, preview.codingProfile, declarationRead?.branch)
330
+ : undefined;
248
331
  const persistOnce = () => options.db.$transaction(async (tx) => {
249
332
  if (lockGroups)
250
333
  await tx.$executeRawUnsafe(GROUP_DISPATCH_LOCK_SQL);
@@ -271,13 +354,30 @@ export async function dispatchRun(options) {
271
354
  // reservation. A native run computes its effective budget in
272
355
  // executeRun's load step instead.
273
356
  let codingBudget;
357
+ let codingEntry;
358
+ let modelFailure;
274
359
  let services = [];
275
360
  let servicesRefusal;
276
361
  if (agent.kind === "coding") {
277
362
  if (!agent.codingProfile)
278
363
  throw new Error(`Coding agent "${agent.id}" has no coding profile.`);
279
- assertCodingProviderModel(agent.codingProfile.provider, agent.model);
280
- if (declarationRead) {
364
+ // An unknown coding provider is a broken profile, not a catalog state: still thrown.
365
+ assertCodingProvider(agent.codingProfile.provider);
366
+ // Recorded on the run row below: the run is billed at this entry for its whole life.
367
+ // A model the catalog cannot run (missing, disabled, or another provider's) fails
368
+ // the run here instead of throwing: the run row commits with the reason, so the
369
+ // caller's own writes stand (a scheduler's lastScheduledAt, a mention's status
370
+ // row) and the owner sees why in list_runs, rather than a rolled-back
371
+ // transaction that a scheduler would retry on every tick.
372
+ try {
373
+ codingEntry = resolveCodingEntry(agent.codingProfile.provider, agent.model);
374
+ }
375
+ catch (err) {
376
+ if (!(err instanceof ModelUnavailableError || err instanceof CodingModelProviderMismatchError))
377
+ throw err;
378
+ modelFailure = err.message;
379
+ }
380
+ if (!modelFailure && declarationRead) {
281
381
  if (declarationRead.repository !== agent.codingProfile.repository ||
282
382
  declarationRead.profileBaseRef !== agent.codingProfile.baseRef) {
283
383
  // The declaration was read for a profile that changed before this transaction.
@@ -291,7 +391,7 @@ export async function dispatchRun(options) {
291
391
  else
292
392
  services = resolved.services;
293
393
  }
294
- if (!servicesRefusal)
394
+ if (!modelFailure && !servicesRefusal)
295
395
  codingBudget = await reserveCodingBudget(tx, agent, now, options);
296
396
  }
297
397
  const refusal = servicesRefusal ?? codingBudget?.refusal;
@@ -304,13 +404,29 @@ export async function dispatchRun(options) {
304
404
  grantedParentMemoryKeys: options.grantedParentMemoryKeys ?? [],
305
405
  taskOverride: options.taskOverride,
306
406
  triggeredById: options.triggeredById ?? null,
407
+ // Failed before it starts, like a native run whose model the
408
+ // runner's load step finds unavailable: zero spend, never started.
409
+ ...(modelFailure ? { status: "failed", error: modelFailure, finishedAt: now } : {}),
307
410
  // Refused before it starts, the same terminal status (and zero
308
411
  // spend) as a native run whose budget is gone at turn 1.
309
412
  ...(refusal ? { status: "refused", error: refusal, finishedAt: now } : {}),
413
+ ...(codingEntry
414
+ ? {
415
+ pricingVersion: codingEntry.priceVersion,
416
+ // entryOf returns plain JSON data (fresh array, no Dates); the interface's readonly
417
+ // efforts array is all that keeps it from matching Prisma's Json input type.
418
+ pricingSnapshot: entryOf(codingEntry),
419
+ }
420
+ : {}),
310
421
  },
311
422
  });
312
423
  if (options.afterPersist)
313
424
  await options.afterPersist(tx, run);
425
+ const attributed = await attributeRun(tx, run.id, {
426
+ parentRunId: options.parentRunId,
427
+ continuesCodingRunId: options.continuesCodingRunId,
428
+ intent: options.attribution,
429
+ }, now);
314
430
  if (agent.kind === "coding" && agent.codingProfile && codingBudget && !refusal) {
315
431
  // Checked above too; repeated so the provider type narrows here.
316
432
  assertCodingProviderModel(agent.codingProfile.provider, agent.model);
@@ -319,7 +435,7 @@ export async function dispatchRun(options) {
319
435
  throw new Error(`Coding agent "${agent.id}" requires a task.`);
320
436
  // The worker sees only the task text, so the agent's own instructions, and the
321
437
  // services note, ride in it.
322
- const task = composeCodingTask(agent.systemPrompt, request, servicesInstructionNote(services));
438
+ const task = composeCodingTask(agent.systemPrompt, request, servicesInstructionNote(services), knowledgeIndex ? knowledgeSection(knowledgeIndex) : undefined);
323
439
  const budgetUsd = codingBudget.budgetUsd;
324
440
  // Resolved once: before the transaction when the declaration was read from it.
325
441
  const branch = declarationRead?.branch ?? (await resolveCodingBranch(tx, options, agent.codingProfile));
@@ -351,6 +467,8 @@ export async function dispatchRun(options) {
351
467
  await tx.codingRun.create({
352
468
  data: {
353
469
  runId: run.id,
470
+ issueProvider: attributed?.provider ?? null,
471
+ issueKey: attributed?.key ?? null,
354
472
  task: input.task,
355
473
  repository: input.repository,
356
474
  baseRef: input.baseRef,
@@ -407,8 +525,8 @@ export async function dispatchRun(options) {
407
525
  }
408
526
  if (!persisted)
409
527
  return null;
410
- if (persisted.run.status === "refused") {
411
- dispatchLog.warn({ runId: persisted.run.id, agentId: options.agentId, reason: persisted.run.error }, "coding run refused at dispatch");
528
+ if (persisted.run.status === "refused" || persisted.run.status === "failed") {
529
+ dispatchLog.warn({ runId: persisted.run.id, agentId: options.agentId, reason: persisted.run.error }, `coding run ${persisted.run.status} at dispatch`);
412
530
  return persisted;
413
531
  }
414
532
  const runStart = async () => {
@@ -416,7 +534,7 @@ export async function dispatchRun(options) {
416
534
  await options.executor.start(persisted.run.id);
417
535
  }
418
536
  catch (err) {
419
- await markRunFailedFromExecutorError(options.db, persisted.run.id, err).catch((err2) => dispatchLog.error({ err: err2, runId: persisted.run.id }, "failed to persist executor start failure"));
537
+ await markRunFailedFromExecutorError(options.db, persisted.run.id, err, options.selfDefects).catch((err2) => dispatchLog.error({ err: err2, runId: persisted.run.id }, "failed to persist executor start failure"));
420
538
  }
421
539
  };
422
540
  if (options.awaitExecution) {