@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,10 @@
1
+ /**
2
+ * Per-run, per-model usage by priced token kind (RunModelUsage). Native runs
3
+ * use one model (Agent.model), written once at finish; coding runs are
4
+ * recomputed from the proxy ledger (prisma-ledger.ts). Always set, never
5
+ * incremented, so a repeat is harmless. Best effort: Run.costUsd stays the
6
+ * budget's source of truth.
7
+ */
8
+ import type { PrismaClient } from "#prisma";
9
+ import type { EngineResult } from "../providers/engine/types.js";
10
+ export declare function recordNativeModelUsage(db: Pick<PrismaClient, "runModelUsage">, runId: string, model: string, usage: EngineResult["usage"]): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { logger } from "./logger.js";
2
+ const log = logger.child({ module: "model-usage" });
3
+ export async function recordNativeModelUsage(db, runId, model, usage) {
4
+ if (usage.tokensIn === 0 && usage.tokensOut === 0 && usage.costUsd === 0)
5
+ return;
6
+ const cached = usage.cachedInputTokens ?? 0;
7
+ const values = {
8
+ freshInputTokens: Math.max(0, usage.tokensIn - cached),
9
+ cachedInputTokens: cached,
10
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,
11
+ outputTokens: usage.tokensOut,
12
+ costUsd: usage.costUsd,
13
+ };
14
+ try {
15
+ await db.runModelUsage.upsert({
16
+ where: { runId_model: { runId, model } },
17
+ create: { runId, model, ...values },
18
+ update: values,
19
+ });
20
+ }
21
+ catch (err) {
22
+ log.warn({ err, runId, model }, "could not record the run's model usage");
23
+ }
24
+ }
@@ -31,13 +31,15 @@
31
31
  * Each pass also completes review-host checks left "in progress" by a run
32
32
  * that ended without reaching `executeRun`'s own finalizer (reaped here as
33
33
  * `lost`, failed to start, failed while loading, ...). One sweep covers all
34
- * of those paths instead of patching each. Mention status comments get the
35
- * same sweep: it also catches a run that ended before its comment was posted.
34
+ * of those paths instead of patching each. Mention status comments, and the
35
+ * status comments of runs started by Jira issue events, get the same sweep:
36
+ * it also catches a run that ended before its comment was posted.
36
37
  */
37
38
  import type { PrismaClient } from "#prisma";
38
39
  import type { Executor } from "../providers/executor/types.js";
39
40
  import type { ReviewHostRegistry } from "../providers/review-host/types.js";
40
- export type ReconcilerDb = Pick<PrismaClient, "run" | "runHostCheck" | "runHostStatus">;
41
+ import type { IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
42
+ export type ReconcilerDb = Pick<PrismaClient, "run" | "runHostCheck" | "runHostStatus" | "runIssueStatus" | "agentIssueProject" | "issuePullRequest" | "codingRun" | "agent" | "$transaction" | "$queryRaw">;
41
43
  /**
42
44
  * How long after a run finishes before its still-open check counts as
43
45
  * orphaned: long enough that the sweep never races `executeRun`'s own
@@ -66,7 +68,7 @@ export declare function closeOrphanedHostChecks(db: Pick<PrismaClient, "runHostC
66
68
  */
67
69
  export declare function closeOrphanedHostStatuses(db: Pick<PrismaClient, "runHostStatus" | "run">, hosts: ReviewHostRegistry | undefined, now?: Date): Promise<void>;
68
70
  /** Runs one reconciliation pass. Returns the number of runs marked `lost`. */
69
- export declare function reconcileOnce(db: ReconcilerDb, now?: Date, heartbeatTimeoutMs?: number, executor?: Executor, reviewHosts?: ReviewHostRegistry): Promise<number>;
71
+ export declare function reconcileOnce(db: ReconcilerDb, now?: Date, heartbeatTimeoutMs?: number, executor?: Executor, reviewHosts?: ReviewHostRegistry, issueTrackers?: IssueTrackerRegistry): Promise<number>;
70
72
  export interface ReconcilerOptions {
71
73
  db?: ReconcilerDb;
72
74
  intervalMs?: number;
@@ -74,6 +76,8 @@ export interface ReconcilerOptions {
74
76
  executor?: Executor;
75
77
  /** Hosts used to complete checks orphaned by runs that ended abnormally; none configured = no sweep. */
76
78
  reviewHosts?: ReviewHostRegistry;
79
+ /** Trackers used to complete issue status comments orphaned the same way; none configured = no sweep. */
80
+ issueTrackers?: IssueTrackerRegistry;
77
81
  }
78
82
  export interface ReconcilerHandle {
79
83
  stop(): void;
@@ -31,15 +31,20 @@
31
31
  * Each pass also completes review-host checks left "in progress" by a run
32
32
  * that ended without reaching `executeRun`'s own finalizer (reaped here as
33
33
  * `lost`, failed to start, failed while loading, ...). One sweep covers all
34
- * of those paths instead of patching each. Mention status comments get the
35
- * same sweep: it also catches a run that ended before its comment was posted.
34
+ * of those paths instead of patching each. Mention status comments, and the
35
+ * status comments of runs started by Jira issue events, get the same sweep:
36
+ * it also catches a run that ended before its comment was posted.
36
37
  */
37
38
  import { HEARTBEAT_TIMEOUT_MS, RECONCILE_INTERVAL_MS } from "./timing.js";
38
39
  import { prisma as defaultDb } from "./db.js";
39
40
  import { logger } from "./logger.js";
40
41
  import { closeOpenHostCheck } from "./review-host-checks.js";
41
42
  import { completeHostStatus } from "./host-status.js";
43
+ import { closeOrphanedIssueStatuses } from "./issue-status.js";
44
+ import { fileSelfDefect } from "./self-defects.js";
42
45
  const reconcilerLog = logger.child({ module: "reconciler" });
46
+ /** A pass waits this long per self-defect, then moves on; the filing finishes (or logs) in the background. */
47
+ const SELF_DEFECT_WAIT = { waitMs: 2_000 };
43
48
  /**
44
49
  * How long after a run finishes before its still-open check counts as
45
50
  * orphaned: long enough that the sweep never races `executeRun`'s own
@@ -115,7 +120,7 @@ export async function closeOrphanedHostStatuses(db, hosts, now = new Date()) {
115
120
  await completeHostStatus(db, run, hosts, { postIfMissing: true });
116
121
  }
117
122
  /** Runs one reconciliation pass. Returns the number of runs marked `lost`. */
118
- export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, executor, reviewHosts) {
123
+ export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, executor, reviewHosts, issueTrackers) {
119
124
  const cutoff = new Date(now.getTime() - heartbeatTimeoutMs);
120
125
  const stale = {
121
126
  executionManaged: true,
@@ -207,6 +212,8 @@ export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = H
207
212
  data: { status: "lost", error: reason, finishedAt: now },
208
213
  });
209
214
  lost += result.count;
215
+ if (result.count > 0)
216
+ await fileSelfDefect(db, issueTrackers, { ...run, status: "lost", error: reason, finishedAt: now }, SELF_DEFECT_WAIT);
210
217
  continue;
211
218
  }
212
219
  const result = await db.run.updateMany({
@@ -214,9 +221,13 @@ export async function reconcileOnce(db, now = new Date(), heartbeatTimeoutMs = H
214
221
  data: { status: "lost", error: reason, finishedAt: now },
215
222
  });
216
223
  lost += result.count;
224
+ // Only after this pass made the row lost (not when another instance won the race); bounded, never throws.
225
+ if (result.count > 0)
226
+ await fileSelfDefect(db, issueTrackers, { ...run, status: "lost", error: reason, finishedAt: now }, SELF_DEFECT_WAIT);
217
227
  }
218
228
  await closeOrphanedHostChecks(db, reviewHosts, now);
219
229
  await closeOrphanedHostStatuses(db, reviewHosts, now);
230
+ await closeOrphanedIssueStatuses(db, issueTrackers, now);
220
231
  return lost;
221
232
  }
222
233
  export function startReconciler(options = {}) {
@@ -224,7 +235,7 @@ export function startReconciler(options = {}) {
224
235
  const intervalMs = options.intervalMs ?? RECONCILE_INTERVAL_MS;
225
236
  const heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS;
226
237
  const timer = setInterval(() => {
227
- reconcileOnce(db, new Date(), heartbeatTimeoutMs, options.executor, options.reviewHosts).catch((err) => {
238
+ reconcileOnce(db, new Date(), heartbeatTimeoutMs, options.executor, options.reviewHosts, options.issueTrackers).catch((err) => {
228
239
  reconcilerLog.error({ err }, "reconcile pass failed");
229
240
  });
230
241
  }, intervalMs);
@@ -272,14 +272,21 @@ export async function handleReviewHostTool(name, argsJson, ctx) {
272
272
  }
273
273
  case "repo_read_file": {
274
274
  const a = ReadFileArgs.parse(parsed);
275
- return JSON.stringify(await host.readFile(link.repository, a.path, a.ref, {
275
+ const read = await host.readFile(link.repository, a.path, a.ref, {
276
276
  startLine: a.startLine ?? 1,
277
277
  maxLines: a.maxLines ?? 400,
278
- }));
278
+ });
279
+ // `text` is the raw window for machine callers; agents get only the numbered `content`.
280
+ if (read.kind === "file") {
281
+ const { text: _text, ...shown } = read;
282
+ return JSON.stringify(shown);
283
+ }
284
+ return JSON.stringify(read);
279
285
  }
280
286
  case "repo_list_files": {
281
287
  const a = ListFilesArgs.parse(parsed);
282
- return JSON.stringify(await host.listFiles(link.repository, a.ref, a.pathPrefix));
288
+ const { ref, count, truncated, files } = await host.listFiles(link.repository, a.ref, a.pathPrefix);
289
+ return JSON.stringify({ ref, count, truncated, files });
283
290
  }
284
291
  case "repo_publish_review": {
285
292
  const a = PublishArgs.parse(parsed);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A run is billed at the model catalog entry recorded when it started, for
3
+ * its whole life: a resumed attempt (crash, pod move, reconciler adoption)
4
+ * reads the entry back from its Run row instead of the live catalog, so an
5
+ * admin's later set_model / disable_model never changes a run already under
6
+ * way. Runs from before the catalog have no stored entry and use the current
7
+ * catalog (identical to the shipped prices they started with).
8
+ */
9
+ import type { Prisma } from "#prisma";
10
+ import type { LlmProvider } from "../providers/llm/types.js";
11
+ import { type CatalogEntry, type ResolvedCatalogEntry } from "../providers/llm/catalog-types.js";
12
+ import type { ModelCatalog } from "../providers/llm/catalog.js";
13
+ import { type CodingProvider } from "../coding/provider.js";
14
+ export interface PinnedPricing {
15
+ entry: CatalogEntry;
16
+ priceVersion: string;
17
+ }
18
+ export interface RunPricingDb {
19
+ run: {
20
+ updateMany(args: {
21
+ where: {
22
+ id: string;
23
+ pricingVersion: null;
24
+ };
25
+ data: {
26
+ pricingVersion: string;
27
+ pricingSnapshot: Prisma.InputJsonValue;
28
+ };
29
+ }): Promise<{
30
+ count: number;
31
+ }>;
32
+ findUnique(args: {
33
+ where: {
34
+ id: string;
35
+ };
36
+ select: {
37
+ pricingVersion: true;
38
+ pricingSnapshot: true;
39
+ };
40
+ }): Promise<{
41
+ pricingVersion: string | null;
42
+ pricingSnapshot: unknown;
43
+ } | null>;
44
+ };
45
+ }
46
+ export declare function pinNativeRunPricing(db: RunPricingDb, run: {
47
+ id: string;
48
+ pricingVersion: string | null;
49
+ pricingSnapshot: unknown;
50
+ }, model: string, llm: LlmProvider): Promise<PinnedPricing | undefined>;
51
+ /** A coding agent's model is in the catalog but belongs to a model provider its coding provider cannot drive. */
52
+ export declare class CodingModelProviderMismatchError extends Error {
53
+ constructor(model: string, provider: CodingProvider);
54
+ }
55
+ /**
56
+ * A coding run's entry at dispatch: in the catalog, enabled, and of this coding provider's model provider.
57
+ * Throws ModelUnavailableError or CodingModelProviderMismatchError; dispatchRun turns either into a failed run.
58
+ */
59
+ export declare function resolveCodingEntry(provider: CodingProvider, model: string, catalog?: ModelCatalog): ResolvedCatalogEntry;
60
+ /** Config-time check for create_agent/update_agent: the model must be runnable here. */
61
+ export declare function assertAgentModelAvailable(model: string, llm?: LlmProvider): void;
@@ -0,0 +1,56 @@
1
+ import { RoutingLlmProvider } from "../providers/llm/routing.js";
2
+ import { entryOf, parseStoredEntry, } from "../providers/llm/catalog-types.js";
3
+ import { currentModelCatalog } from "../providers/llm/catalog-store.js";
4
+ import { codingProviderForModelProvider } from "../coding/provider.js";
5
+ function stored(row) {
6
+ const entry = parseStoredEntry(row.pricingSnapshot);
7
+ return entry && row.pricingVersion ? { entry, priceVersion: row.pricingVersion } : undefined;
8
+ }
9
+ export async function pinNativeRunPricing(db, run, model, llm) {
10
+ const existing = stored(run);
11
+ if (existing)
12
+ return existing;
13
+ if (!(llm instanceof RoutingLlmProvider))
14
+ return undefined;
15
+ const resolved = llm.entryFor(model); // throws ModelUnavailableError before any spend
16
+ const entry = entryOf(resolved);
17
+ const { count } = await db.run.updateMany({
18
+ where: { id: run.id, pricingVersion: null },
19
+ // entryOf returns plain JSON data (fresh array, no Dates); the interface's readonly
20
+ // efforts array is all that keeps it from matching Prisma's Json input type.
21
+ data: { pricingVersion: resolved.priceVersion, pricingSnapshot: entry },
22
+ });
23
+ if (count === 1)
24
+ return { entry, priceVersion: resolved.priceVersion };
25
+ // Another attempt of this run recorded first: bill at what it recorded.
26
+ const row = await db.run.findUnique({
27
+ where: { id: run.id },
28
+ select: { pricingVersion: true, pricingSnapshot: true },
29
+ });
30
+ return (row && stored(row)) ?? { entry, priceVersion: resolved.priceVersion };
31
+ }
32
+ /** A coding agent's model is in the catalog but belongs to a model provider its coding provider cannot drive. */
33
+ export class CodingModelProviderMismatchError extends Error {
34
+ constructor(model, provider) {
35
+ super(`Model "${model}" is not supported by coding provider "${provider}".`);
36
+ this.name = "CodingModelProviderMismatchError";
37
+ }
38
+ }
39
+ /**
40
+ * A coding run's entry at dispatch: in the catalog, enabled, and of this coding provider's model provider.
41
+ * Throws ModelUnavailableError or CodingModelProviderMismatchError; dispatchRun turns either into a failed run.
42
+ */
43
+ export function resolveCodingEntry(provider, model, catalog = currentModelCatalog()) {
44
+ const entry = catalog.require(model);
45
+ if (codingProviderForModelProvider(entry.provider) !== provider) {
46
+ throw new CodingModelProviderMismatchError(model, provider);
47
+ }
48
+ return entry;
49
+ }
50
+ /** Config-time check for create_agent/update_agent: the model must be runnable here. */
51
+ export function assertAgentModelAvailable(model, llm) {
52
+ if (llm instanceof RoutingLlmProvider)
53
+ llm.entryFor(model);
54
+ else
55
+ currentModelCatalog().require(model);
56
+ }
@@ -21,6 +21,7 @@ import type { ProviderRegistry } from "../providers/index.js";
21
21
  import { type StepRunner } from "../providers/engine/types.js";
22
22
  import type { Executor } from "../providers/executor/types.js";
23
23
  import type { ReviewHostRegistry } from "../providers/review-host/types.js";
24
+ import type { IssueTrackerRegistry } from "../providers/issue-tracker/types.js";
24
25
  import { type RepoAccessGate } from "./repo-access.js";
25
26
  /**
26
27
  * The subset of the Prisma client the runner touches — mockable in tests.
@@ -28,11 +29,13 @@ import { type RepoAccessGate } from "./repo-access.js";
28
29
  * also satisfies `dispatch.ts`'s `DispatchDb`, needed for dispatching a
29
30
  * coding-kind sub-agent from inside a running native turn loop.
30
31
  */
31
- export type RunnerDb = Pick<PrismaClient, "agent" | "run" | "agentTool" | "agentSecret" | "agentDatastore" | "budgetGroup" | "agentSubAgent" | "resourceGrant" | "codingRun" | "task" | "webhook" | "$transaction" | "$queryRaw" | "agentRepository" | "runHostCheck" | "runHostStatus" | "hostIdentity">;
32
- /** The providers a native run needs; `executor` and `reviewHosts` are optional capabilities. */
32
+ export type RunnerDb = Pick<PrismaClient, "agent" | "run" | "agentTool" | "agentSecret" | "agentDatastore" | "budgetGroup" | "agentSubAgent" | "resourceGrant" | "codingRun" | "task" | "webhook" | "$transaction" | "$queryRaw" | "agentRepository" | "runModelUsage" | "runHostCheck" | "runHostStatus" | "hostIdentity" | "agentIssueProject" | "issuePullRequest" | "runIssueStatus" | "issueFingerprint">;
33
+ /** The providers a native run needs; `executor`, `reviewHosts` and `issueTrackers` are optional capabilities. */
33
34
  export type NativeRunProviders = Pick<ProviderRegistry, "llm" | "engine" | "datastore" | "secrets" | "memory"> & {
34
35
  executor?: ProviderRegistry["executor"];
35
36
  reviewHosts?: ReviewHostRegistry;
37
+ /** Issue trackers (Jira): the jira_* built-ins and issue status comments. */
38
+ issueTrackers?: IssueTrackerRegistry;
36
39
  /** Repository authorization for repo_* calls; built from reviewHosts when absent. */
37
40
  repoAccess?: RepoAccessGate;
38
41
  };
@@ -27,7 +27,7 @@ import { buildSecretsAccessor, scopeSecretsAccessor } from "./secrets.js";
27
27
  import { buildSharedDatastoreAccessor, scopeSharedDatastoreAccessor } from "./datastores.js";
28
28
  import { effectiveBudgetForRun } from "./budget-groups.js";
29
29
  import { withRunHeartbeat } from "./run-heartbeat.js";
30
- import { dispatchRun } from "./dispatch.js";
30
+ import { ContinuationRefusedError, dispatchRun } from "./dispatch.js";
31
31
  import { canDelegate } from "./grants.js";
32
32
  import { MEMORY_TOOL_DEFS, MEMORY_TOOL_NAMES, handleMemoryTool } from "./memory-tools.js";
33
33
  import { DELEGATE_TOOL_PREFIX, duplicateToolNames } from "./tool-names.js";
@@ -40,6 +40,14 @@ import { closeOpenHostCheck } from "./review-host-checks.js";
40
40
  import { serviceRefusalSentence } from "../coding/services/wording.js";
41
41
  import { RUN_TASK_TAG, splitTaskOverride, wrapUntrusted } from "./untrusted-content.js";
42
42
  import { completeHostStatus } from "./host-status.js";
43
+ import { ISSUE_TRACKER_TOOL_DEFS, ISSUE_TRACKER_TOOL_NAMES, handleIssueTrackerTool, } from "./issue-tracker-tools.js";
44
+ import { completeIssueStatus } from "./issue-status.js";
45
+ import { fileIssue } from "./issue-dedupe.js";
46
+ import { fileSelfDefect } from "./self-defects.js";
47
+ import { recordNativeModelUsage } from "./model-usage.js";
48
+ import { pinNativeRunPricing } from "./run-pricing.js";
49
+ import { RoutingLlmProvider } from "../providers/llm/routing.js";
50
+ import { ModelUnavailableError } from "../providers/llm/catalog-types.js";
43
51
  import { trackRun } from "./in-flight-runs.js";
44
52
  import { createRepoAccessGate, requiredLevel } from "./repo-access.js";
45
53
  const runnerLog = logger.child({ module: "runner" });
@@ -115,6 +123,30 @@ const DelegateArgs = z
115
123
  function configuredReviewHosts(hosts) {
116
124
  return hosts && Object.values(hosts).some(Boolean) ? hosts : undefined;
117
125
  }
126
+ /**
127
+ * The composed issue trackers, or undefined when there are none (no Jira site
128
+ * configured yields an empty registry). Undefined means the run never touches
129
+ * the AgentIssueProject/RunIssueStatus tables at all.
130
+ */
131
+ function configuredIssueTrackers(trackers) {
132
+ return trackers && Object.values(trackers).some(Boolean) ? trackers : undefined;
133
+ }
134
+ /** An AgentIssueProject row as the jira_* tools see it. */
135
+ function toIssueProjectLink(row) {
136
+ return {
137
+ provider: "jira",
138
+ projectKey: row.projectKey,
139
+ access: row.access === "write" ? "write" : "read",
140
+ commentVisibilityRole: row.commentVisibilityRole,
141
+ // `?? []` (fail closed): a row or pinned load from before the allowlists existed allows nothing.
142
+ allowedTransitions: row.allowedTransitions ?? [],
143
+ writableFields: row.writableFields ?? [],
144
+ allowedLinkTypes: row.allowedLinkTypes ?? [],
145
+ creatableIssueTypes: row.creatableIssueTypes ?? [],
146
+ // No built-in cap: absent (an older pinned load) means none, like null.
147
+ maxNewIssuesPerRun: row.maxNewIssuesPerRun ?? null,
148
+ };
149
+ }
118
150
  /**
119
151
  * The two states a Run can still be driven out of. Every write `executeRun`
120
152
  * makes is filtered on these: a durable executor can have two attempts of the
@@ -143,8 +175,12 @@ export class RunCancelledError extends Error {
143
175
  * the race, the winner's if it did not.
144
176
  */
145
177
  async function finishRun(db, runId, data) {
146
- await db.run.updateMany({ where: { id: runId, status: { in: [...DRIVABLE] } }, data });
147
- return db.run.findUniqueOrThrow({ where: { id: runId } });
178
+ return (await finishRunClaimed(db, runId, data)).run;
179
+ }
180
+ /** finishRun, also saying whether this call made the row terminal (false: something else finished it first). */
181
+ async function finishRunClaimed(db, runId, data) {
182
+ const updated = await db.run.updateMany({ where: { id: runId, status: { in: [...DRIVABLE] } }, data });
183
+ return { run: await db.run.findUniqueOrThrow({ where: { id: runId } }), claimed: updated.count > 0 };
148
184
  }
149
185
  /**
150
186
  * Why a coding run cannot start in this process: runs reach the runner only
@@ -256,16 +292,23 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
256
292
  const repoAccess = reviewHosts
257
293
  ? (providers.repoAccess ?? createRepoAccessGate({ db, hosts: reviewHosts }))
258
294
  : undefined;
295
+ const issueTrackers = configuredIssueTrackers(providers.issueTrackers);
259
296
  // Pinned in one checkpointed step: on replay after a crash, the agent row
260
297
  // or its budget group may have changed since first execution. The
261
298
  // engine's control flow depends on budgetUsd and maxTurns, so they must
262
299
  // be pinned to the values seen on first execution or the replay's step
263
300
  // order diverges from the record.
264
- const loaded = await step("load", async () => {
301
+ const loadedOrUnavailable = await step("load", async () => {
265
302
  const agent = await db.agent.findUnique({ where: { id: existingRun.agentId } });
266
303
  if (!agent) {
267
304
  throw new Error(`Run "${runId}" references missing agent "${existingRun.agentId}".`);
268
305
  }
306
+ // The run's catalog entry, recorded on first execution and read back on
307
+ // every replay or resume, so its prices never move under it (run-pricing.ts).
308
+ // A model that is missing, disabled, or has no configured provider fails
309
+ // the run here, before any spend. Coding agents never run in this engine
310
+ // (failed below) and are priced by the coding proxy, so they pin nothing.
311
+ const pricing = agent.kind === "coding" ? undefined : await pinNativeRunPricing(db, existingRun, agent.model, providers.llm);
269
312
  const attached = await db.agentTool.findMany({
270
313
  where: { agentId: agent.id },
271
314
  include: { tool: true },
@@ -301,6 +344,10 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
301
344
  checkName: l.checkName,
302
345
  }))
303
346
  : [];
347
+ // Likewise only when a Jira site is configured.
348
+ const issueProjectLinks = issueTrackers
349
+ ? (await db.agentIssueProject.findMany({ where: { agentId: agent.id, provider: "jira" } })).map(toIssueProjectLink)
350
+ : [];
304
351
  const isDispatchedChild = existingRun.parentRunId != null;
305
352
  // Appended, never prepended: the loaded systemPrompt's stable prefix
306
353
  // stays prompt-cache-eligible across every dispatch, even though the
@@ -325,9 +372,11 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
325
372
  return {
326
373
  agentId: agent.id,
327
374
  kind: agent.kind,
375
+ pricing,
328
376
  memoryEnabled: agent.memoryEnabled,
329
377
  subAgentEdges,
330
378
  repositoryLinks,
379
+ issueProjectLinks,
331
380
  agent: {
332
381
  systemPrompt,
333
382
  model: agent.model,
@@ -360,6 +409,7 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
360
409
  ...(isDispatchedChild ? [PARENT_MEMORY_GET_TOOL] : []),
361
410
  ...subAgentEdges.map((edge) => delegateToolDef(edge.boundName)),
362
411
  ...(repositoryLinks.length > 0 ? REVIEW_HOST_TOOL_DEFS : []),
412
+ ...(issueProjectLinks.length > 0 ? ISSUE_TRACKER_TOOL_DEFS : []),
363
413
  ],
364
414
  // An attachment's four capability fields are honoured only when the
365
415
  // agent's CURRENT owner granted them (resource-sharing grants spec
@@ -381,14 +431,33 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
381
431
  ];
382
432
  })),
383
433
  };
434
+ }).catch((err) => {
435
+ // Returned, not thrown, so the run is marked failed below rather than left pending for a retry
436
+ // that would fail the same way. Matched by message too: a DBOS replay may hand back a
437
+ // deserialized error that is no longer a ModelUnavailableError instance.
438
+ if (err instanceof ModelUnavailableError ||
439
+ (err instanceof Error && err.message.startsWith("model_unavailable:"))) {
440
+ return { unavailable: err.message };
441
+ }
442
+ throw err;
384
443
  });
385
- if (loaded.kind === "coding") {
386
- return finishRun(db, runId, {
387
- status: "failed",
388
- error: CODING_EXECUTOR_NOT_CONFIGURED,
389
- finishedAt: new Date(),
390
- });
391
- }
444
+ // Fails a run that ends before its engine starts, closing what its trigger opened (the review
445
+ // check, the host and issue status comments) exactly as the normal and catch paths below do.
446
+ // Deliberately no self-defect: both callers are configuration states, not this run's defect (an
447
+ // admin disabled or removed the model, or this deployment has no coding executor), and filing
448
+ // would open one defect per affected agent rather than describe a failure of that agent.
449
+ const finishEarly = async (error) => {
450
+ const finished = await finishRun(db, runId, { status: "failed", error, finishedAt: new Date() });
451
+ await closeOpenHostCheck(db, finished, reviewHosts);
452
+ await completeHostStatus(db, finished, reviewHosts);
453
+ await completeIssueStatus(db, finished, issueTrackers);
454
+ return finished;
455
+ };
456
+ if ("unavailable" in loadedOrUnavailable)
457
+ return finishEarly(loadedOrUnavailable.unavailable);
458
+ const loaded = loadedOrUnavailable;
459
+ if (loaded.kind === "coding")
460
+ return finishEarly(CODING_EXECUTOR_NOT_CONFIGURED);
392
461
  // Conditional on DRIVABLE rather than on `pending`: an adopted attempt
393
462
  // legitimately finds the row already `running`, but a terminal row must
394
463
  // never be flipped back to `running`.
@@ -397,6 +466,9 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
397
466
  const toolsByName = new Map(Object.entries(loaded.toolsByName));
398
467
  const secretsAccessor = buildSecretsAccessor(loaded.agentId, providers.secrets, db);
399
468
  const sharedDatastoreAccessor = buildSharedDatastoreAccessor(loaded.agentId, providers.datastore, db);
469
+ // jira_create_issue's per-run cap counter: shared by every tool call of this attempt (a resumed attempt
470
+ // starts a fresh one, floored by the run's recorded fingerprint creates).
471
+ const issueCreationCounters = new Map();
400
472
  const runSandboxTool = async (name, argsJson) => {
401
473
  if (loaded.memoryEnabled && MEMORY_TOOL_NAMES.has(name)) {
402
474
  return handleMemoryTool(name, argsJson, loaded.agentId, providers.memory);
@@ -449,6 +521,30 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
449
521
  },
450
522
  });
451
523
  }
524
+ // `?? []`: a load step replayed from before these links were pinned has none.
525
+ // Each link is re-normalised too: one pinned before the allowlists existed lacks them.
526
+ const issueProjectLinks = (loaded.issueProjectLinks ?? []).map(toIssueProjectLink);
527
+ if (ISSUE_TRACKER_TOOL_NAMES.has(name) && issueProjectLinks.length > 0 && issueTrackers) {
528
+ return handleIssueTrackerTool(name, argsJson, {
529
+ agentId: loaded.agentId,
530
+ links: issueProjectLinks,
531
+ trackers: issueTrackers,
532
+ // Live, not from the pinned load: an unlink, downgrade, or new
533
+ // visibility role takes effect on the very next call.
534
+ currentLink: async (projectKey) => {
535
+ const row = await db.agentIssueProject.findUnique({
536
+ where: { agentId_provider_projectKey: { agentId: loaded.agentId, provider: "jira", projectKey } },
537
+ });
538
+ return row ? toIssueProjectLink(row) : null;
539
+ },
540
+ creation: {
541
+ runId,
542
+ counters: issueCreationCounters,
543
+ fileIssue: (input) => fileIssue({ db }, input),
544
+ recordedCreates: (projectKey) => db.issueFingerprint.count({ where: { createdByRunId: runId, issueProvider: "jira", projectKey } }),
545
+ },
546
+ });
547
+ }
452
548
  if (name === "subagent_memory_get") {
453
549
  return handleSubAgentMemoryGet(argsJson, loaded.agentId, db, providers.memory);
454
550
  }
@@ -567,19 +663,35 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
567
663
  // dispatchRun reserves the child's budget itself, inside its persist
568
664
  // transaction: the agent's budgetUsd tightened by its budget group and
569
665
  // by this run tree (parentRunId), or refused when either is spent.
570
- const dispatched = await dispatchRun({
571
- db,
572
- executor: providers.executor,
573
- agentId: edge.childAgentId,
574
- trigger: "subagent",
575
- codingTask: args.task,
576
- continuesCodingRunId: args.continuePriorRun,
577
- parentRunId: runId,
578
- // The child's result flows back into this run, which the
579
- // triggerer sees, so the child is visible to them too.
580
- triggeredById: existingRun.triggeredById,
581
- awaitExecution: true,
582
- });
666
+ let dispatched;
667
+ try {
668
+ dispatched = await dispatchRun({
669
+ db,
670
+ executor: providers.executor,
671
+ selfDefects: { db, issueTrackers },
672
+ agentId: edge.childAgentId,
673
+ trigger: "subagent",
674
+ codingTask: args.task,
675
+ continuesCodingRunId: args.continuePriorRun,
676
+ parentRunId: runId,
677
+ // The child's result flows back into this run, which the
678
+ // triggerer sees, so the child is visible to them too.
679
+ triggeredById: existingRun.triggeredById,
680
+ awaitExecution: true,
681
+ });
682
+ }
683
+ catch (err) {
684
+ // A continuation this deployment cannot make (the run id came from
685
+ // a pull request another deployment opened, say) is the model's to
686
+ // report, not a reason to fail the whole run.
687
+ if (!(err instanceof ContinuationRefusedError))
688
+ throw err;
689
+ return JSON.stringify({
690
+ error: "continuation_refused",
691
+ message: `${err.message} No sub-agent run was started. Do not open a new pull request in its place: ` +
692
+ "tell the requester that this wardby deployment cannot continue that pull request's branch.",
693
+ });
694
+ }
583
695
  if (!dispatched) {
584
696
  return JSON.stringify({ error: "dispatch_failed", message: "The sub-agent run could not be created." });
585
697
  }
@@ -676,15 +788,42 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
676
788
  }
677
789
  return JSON.stringify(result.value);
678
790
  };
791
+ // Live progress for observers (MCP get_run, the viewer). Absolute totals, so a DBOS replay
792
+ // re-writing them is harmless. Written into the run's own cost columns on purpose: the run
793
+ // tree's shared budget (computeRunTreeSpend) then counts a running parent's spend so far, as it
794
+ // already does for coding runs, whose proxy ledger writes their totals live. Best-effort: a
795
+ // failed write only delays what observers see, and finishRun writes the final totals anyway.
796
+ const onProgress = async (progress) => {
797
+ try {
798
+ await db.run.updateMany({
799
+ where: { id: runId, status: "running" },
800
+ data: {
801
+ turns: progress.turns,
802
+ tokensIn: progress.usage.tokensIn,
803
+ tokensOut: progress.usage.tokensOut,
804
+ costUsd: progress.usage.costUsd,
805
+ heartbeatAt: new Date(),
806
+ },
807
+ });
808
+ }
809
+ catch (err) {
810
+ runnerLog.warn({ err, runId }, "failed to record run progress");
811
+ }
812
+ };
679
813
  const engineResult = await providers.engine.run({
680
814
  agent: loaded.agent,
681
815
  tools: loaded.tools,
682
- providers: { llm: providers.llm },
816
+ providers: {
817
+ llm: loaded.pricing && providers.llm instanceof RoutingLlmProvider
818
+ ? providers.llm.forRun(loaded.pricing.entry)
819
+ : providers.llm,
820
+ },
683
821
  runSandboxTool,
684
822
  onText,
823
+ onProgress,
685
824
  step,
686
825
  });
687
- const finished = await finishRun(db, runId, {
826
+ const { run: finished, claimed } = await finishRunClaimed(db, runId, {
688
827
  status: engineResult.status,
689
828
  tokensIn: engineResult.usage.tokensIn,
690
829
  tokensOut: engineResult.usage.tokensOut,
@@ -694,8 +833,15 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
694
833
  turns: engineResult.turns,
695
834
  finishedAt: new Date(),
696
835
  });
836
+ // Per-model usage is written on the same path as Run.costUsd: any future mid-run cost write must write usage too.
837
+ await recordNativeModelUsage(db, runId, loaded.agent.model, engineResult.usage);
697
838
  await closeOpenHostCheck(db, finished, reviewHosts);
698
839
  await completeHostStatus(db, finished, reviewHosts);
840
+ await completeIssueStatus(db, finished, issueTrackers);
841
+ // Only the call that made the row terminal files, so a run another finalizer (the reconciler) ended is not
842
+ // filed twice. Bounded and never throws.
843
+ if (claimed)
844
+ await fileSelfDefect(db, issueTrackers, finished);
699
845
  return finished;
700
846
  }
701
847
  catch (err) {
@@ -704,13 +850,18 @@ async function executeTrackedRun(runId, providers, db, onText, step) {
704
850
  // (a real bug, or tool-loading failing outside the per-tool try above)
705
851
  // must still never leave the run dangling in "running". A cancellation
706
852
  // is not a failure: it carries the operator's own reason.
707
- const finished = await finishRun(db, runId, {
853
+ const { run: finished, claimed } = await finishRunClaimed(db, runId, {
708
854
  status: err instanceof RunCancelledError ? "cancelled" : "failed",
709
855
  error: err instanceof Error ? err.message : String(err),
710
856
  finishedAt: new Date(),
711
857
  });
712
858
  await closeOpenHostCheck(db, finished, reviewHosts);
713
859
  await completeHostStatus(db, finished, reviewHosts);
860
+ await completeIssueStatus(db, finished, issueTrackers);
861
+ // Only the call that made the row terminal files, so a run another finalizer (the reconciler) ended is not
862
+ // filed twice. Bounded and never throws.
863
+ if (claimed)
864
+ await fileSelfDefect(db, issueTrackers, finished);
714
865
  return finished;
715
866
  }
716
867
  }