@wardby/cli 0.4.1 → 0.5.0

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 (221) hide show
  1. package/.env.example +8 -1
  2. package/README.md +3 -1
  3. package/deploy/local/docker-compose.quickstart-coding.yml +27 -0
  4. package/dist/claude-coding-worker/driver.d.ts +2 -0
  5. package/dist/claude-coding-worker/driver.js +19 -3
  6. package/dist/claude-coding-worker/sdk.js +1 -1
  7. package/dist/cli.js +41 -22
  8. package/dist/coding/continuation-wording.d.ts +10 -0
  9. package/dist/coding/continuation-wording.js +13 -0
  10. package/dist/coding/docker-preflight.d.ts +12 -0
  11. package/dist/coding/docker-preflight.js +40 -0
  12. package/dist/coding/local-git.d.ts +22 -0
  13. package/dist/coding/local-git.js +102 -0
  14. package/dist/coding/local-repo-status.d.ts +7 -0
  15. package/dist/coding/local-repo-status.js +56 -0
  16. package/dist/coding/local-repo.d.ts +17 -0
  17. package/dist/coding/local-repo.js +65 -0
  18. package/dist/coding/observability.d.ts +1 -1
  19. package/dist/coding/observability.js +1 -0
  20. package/dist/coding/profile.d.ts +6 -0
  21. package/dist/coding/profile.js +13 -4
  22. package/dist/coding/protocol.d.ts +32 -7
  23. package/dist/coding/protocol.js +66 -4
  24. package/dist/coding-worker/artifact.d.ts +1 -0
  25. package/dist/coding-worker/errors.js +2 -0
  26. package/dist/config/providers.d.ts +13 -0
  27. package/dist/config/providers.js +21 -9
  28. package/dist/core/attribution.d.ts +13 -0
  29. package/dist/core/attribution.js +30 -0
  30. package/dist/core/budget-groups.d.ts +3 -1
  31. package/dist/core/budget-groups.js +53 -5
  32. package/dist/core/ci-context.d.ts +8 -0
  33. package/dist/core/ci-context.js +59 -0
  34. package/dist/core/delegation-siblings.d.ts +22 -0
  35. package/dist/core/delegation-siblings.js +40 -0
  36. package/dist/core/dispatch.d.ts +28 -4
  37. package/dist/core/dispatch.js +57 -15
  38. package/dist/core/engine-native.d.ts +16 -0
  39. package/dist/core/engine-native.js +49 -2
  40. package/dist/core/host-events.d.ts +25 -2
  41. package/dist/core/host-events.js +361 -19
  42. package/dist/core/host-status.d.ts +8 -3
  43. package/dist/core/host-status.js +38 -7
  44. package/dist/core/issue-events.d.ts +2 -6
  45. package/dist/core/issue-events.js +13 -19
  46. package/dist/core/pull-request-state-sync.d.ts +17 -0
  47. package/dist/core/pull-request-state-sync.js +53 -0
  48. package/dist/core/reconciler.d.ts +10 -1
  49. package/dist/core/reconciler.js +15 -2
  50. package/dist/core/related-pull-requests.d.ts +95 -0
  51. package/dist/core/related-pull-requests.js +338 -0
  52. package/dist/core/repo-access.d.ts +5 -1
  53. package/dist/core/repo-access.js +17 -1
  54. package/dist/core/review-fix-ledger.d.ts +20 -0
  55. package/dist/core/review-fix-ledger.js +41 -0
  56. package/dist/core/review-fix.d.ts +60 -0
  57. package/dist/core/review-fix.js +155 -0
  58. package/dist/core/review-host-tools.d.ts +13 -2
  59. package/dist/core/review-host-tools.js +72 -11
  60. package/dist/core/runner.d.ts +2 -2
  61. package/dist/core/runner.js +379 -184
  62. package/dist/core/serial-gate.d.ts +11 -0
  63. package/dist/core/serial-gate.js +19 -0
  64. package/dist/core/timing.d.ts +7 -0
  65. package/dist/core/timing.js +7 -0
  66. package/dist/generated/prisma/browser.d.ts +23 -0
  67. package/dist/generated/prisma/client.d.ts +23 -0
  68. package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
  69. package/dist/generated/prisma/internal/class.d.ts +33 -0
  70. package/dist/generated/prisma/internal/class.js +4 -4
  71. package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
  72. package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
  73. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
  74. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
  75. package/dist/generated/prisma/models/Agent.d.ts +422 -1
  76. package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
  77. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
  78. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  79. package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
  80. package/dist/generated/prisma/models/DeferredReview.js +1 -0
  81. package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
  82. package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
  83. package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
  84. package/dist/generated/prisma/models/LocalReview.js +1 -0
  85. package/dist/generated/prisma/models/Run.d.ts +223 -0
  86. package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
  87. package/dist/generated/prisma/models.d.ts +3 -0
  88. package/dist/help-index.json +476 -37
  89. package/dist/import/neutral-schema.d.ts +2 -2
  90. package/dist/mcp/auth/access.d.ts +3 -1
  91. package/dist/mcp/auth/host-account-cli.js +2 -2
  92. package/dist/mcp/auth/repo-authorization.d.ts +6 -7
  93. package/dist/mcp/auth/repo-authorization.js +21 -0
  94. package/dist/mcp/index.js +4 -1
  95. package/dist/mcp/tools/agents.js +46 -4
  96. package/dist/mcp/tools/host-accounts.js +2 -2
  97. package/dist/mcp/tools/repositories.js +71 -12
  98. package/dist/mcp/tools/runs.js +23 -0
  99. package/dist/mcp/tools/trigger.js +153 -24
  100. package/dist/providers/engine/types.d.ts +11 -0
  101. package/dist/providers/executor/composition.js +12 -7
  102. package/dist/providers/executor/container.d.ts +40 -3
  103. package/dist/providers/executor/container.js +140 -9
  104. package/dist/providers/executor/types.d.ts +1 -1
  105. package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
  106. package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
  107. package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
  108. package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
  109. package/dist/providers/jobs/kubernetes-client.js +3 -0
  110. package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
  111. package/dist/providers/jobs/kubernetes-isolation.js +8 -3
  112. package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
  113. package/dist/providers/jobs/kubernetes-preflight.js +7 -0
  114. package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
  115. package/dist/providers/jobs/kubernetes-quota.js +110 -0
  116. package/dist/providers/jobs/kubernetes.d.ts +9 -0
  117. package/dist/providers/jobs/kubernetes.js +50 -0
  118. package/dist/providers/jobs/types.d.ts +7 -0
  119. package/dist/providers/review-host/ci.d.ts +7 -0
  120. package/dist/providers/review-host/ci.js +20 -0
  121. package/dist/providers/review-host/github-events.d.ts +7 -0
  122. package/dist/providers/review-host/github-events.js +31 -7
  123. package/dist/providers/review-host/github.d.ts +19 -2
  124. package/dist/providers/review-host/github.js +145 -2
  125. package/dist/providers/review-host/index.d.ts +8 -2
  126. package/dist/providers/review-host/index.js +14 -3
  127. package/dist/providers/review-host/local.d.ts +72 -0
  128. package/dist/providers/review-host/local.js +434 -0
  129. package/dist/providers/review-host/types.d.ts +65 -3
  130. package/dist/providers/review-host/types.js +5 -3
  131. package/dist/providers/vcs/git.d.ts +10 -20
  132. package/dist/providers/vcs/git.js +71 -127
  133. package/dist/providers/vcs/github-remote.d.ts +67 -0
  134. package/dist/providers/vcs/github-remote.js +201 -0
  135. package/dist/providers/vcs/github.d.ts +81 -0
  136. package/dist/providers/vcs/github.js +203 -6
  137. package/dist/providers/vcs/index.d.ts +16 -1
  138. package/dist/providers/vcs/index.js +43 -15
  139. package/dist/providers/vcs/local-remote.d.ts +44 -0
  140. package/dist/providers/vcs/local-remote.js +108 -0
  141. package/dist/providers/vcs/remote.d.ts +58 -0
  142. package/dist/providers/vcs/remote.js +1 -0
  143. package/dist/providers/vcs/routing.d.ts +33 -0
  144. package/dist/providers/vcs/routing.js +52 -0
  145. package/dist/providers/vcs/types.d.ts +12 -1
  146. package/dist/quickstart/coding-db.d.ts +11 -0
  147. package/dist/quickstart/coding-db.js +75 -0
  148. package/dist/quickstart/coding-doctor.d.ts +13 -0
  149. package/dist/quickstart/coding-doctor.js +88 -0
  150. package/dist/quickstart/coding-images.d.ts +15 -0
  151. package/dist/quickstart/coding-images.js +79 -0
  152. package/dist/quickstart/coding-seed.d.ts +63 -0
  153. package/dist/quickstart/coding-seed.js +85 -0
  154. package/dist/quickstart/coding.d.ts +66 -0
  155. package/dist/quickstart/coding.js +430 -0
  156. package/dist/quickstart/images.d.ts +19 -0
  157. package/dist/quickstart/images.js +63 -0
  158. package/dist/quickstart/index.js +61 -12
  159. package/dist/quickstart/starter-services.d.ts +45 -0
  160. package/dist/quickstart/starter-services.js +186 -0
  161. package/dist/quickstart-images.json +1 -0
  162. package/dist/serve.js +14 -1
  163. package/dist/viewer/api-schema.d.ts +176 -0
  164. package/dist/viewer/api-schema.js +27 -2
  165. package/dist/viewer/graph.d.ts +7 -2
  166. package/dist/viewer/graph.js +25 -11
  167. package/dist/viewer/http.d.ts +7 -1
  168. package/dist/viewer/http.js +8 -3
  169. package/dist/viewer/infra.d.ts +2 -0
  170. package/dist/viewer/infra.js +23 -0
  171. package/dist/viewer/run-detail.d.ts +2 -1
  172. package/dist/viewer/run-detail.js +2 -2
  173. package/docs/agent-recipes.md +123 -2
  174. package/docs/code-review-agents.md +293 -38
  175. package/docs/coding-agent-setup.md +141 -2
  176. package/docs/coding-packages.md +21 -0
  177. package/docs/coding-services.md +3 -0
  178. package/docs/coding-worker-isolation.md +56 -20
  179. package/docs/getting-started-gke.md +24 -0
  180. package/docs/getting-started.md +82 -8
  181. package/docs/jira-agents.md +26 -5
  182. package/docs/security-deployment.md +9 -0
  183. package/docs/viewer-api.md +21 -2
  184. package/help/admin-viewer.md +11 -1
  185. package/help/agent-recipes.md +25 -1
  186. package/help/builder-agent.md +4 -0
  187. package/help/code-review-agents.md +82 -2
  188. package/help/coding-packages.md +12 -1
  189. package/help/coding-services.md +4 -0
  190. package/help/cost-attribution.md +3 -1
  191. package/help/creating-agents.md +3 -0
  192. package/help/deploy-gke.md +2 -1
  193. package/help/errors/coding-provider-not-configured.md +60 -0
  194. package/help/errors/coding-turn-limit.md +25 -0
  195. package/help/errors/continuation-closed.md +75 -0
  196. package/help/errors/local-branch-conflict.md +37 -0
  197. package/help/errors/local-path-invalid.md +30 -0
  198. package/help/errors/local-ref-invalid.md +36 -0
  199. package/help/errors/local-ref-not-found.md +41 -0
  200. package/help/errors/local-repo-not-allowed.md +46 -0
  201. package/help/errors/local-repo-not-found.md +39 -0
  202. package/help/errors/model-unavailable.md +7 -6
  203. package/help/errors/vcs-github-not-configured.md +34 -0
  204. package/help/getting-started.md +4 -0
  205. package/help/github.md +22 -5
  206. package/help/jira.md +9 -2
  207. package/help/local-repositories.md +182 -0
  208. package/help/related-pull-requests.md +89 -0
  209. package/help/review-fix-rounds.md +69 -0
  210. package/help/troubleshooting/budgets.md +11 -0
  211. package/help/troubleshooting/coding-workers.md +22 -0
  212. package/help/troubleshooting/repository-access.md +5 -0
  213. package/package.json +3 -2
  214. package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
  215. package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
  216. package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
  217. package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
  218. package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
  219. package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
  220. package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
  221. package/prisma/schema.prisma +98 -1
@@ -9,8 +9,11 @@ import { parseStoredServices, storedServiceNames, workerServices } from "../../c
9
9
  import { MAX_SERVICE_DECLARATION_BYTES, SERVICE_DECLARATION_PATH } from "../../coding/services/declaration.js";
10
10
  import { SERVICE_UNREADY_CATEGORY, SERVICE_UNREADY_ERROR, serviceUnreadyName, serviceUnreadySentence, } from "../../coding/services/wording.js";
11
11
  import { PROTECTED_PATH_CATEGORY, protectedPathFromError, protectedPathSentence, } from "../../coding/protected-path-wording.js";
12
+ import { CONTINUATION_CLOSED_CATEGORY, CONTINUATION_CLOSED_ERROR } from "../../coding/continuation-wording.js";
12
13
  import { budgetSentence } from "../../core/budget-wording.js";
13
14
  import { fileSelfDefect } from "../../core/self-defects.js";
15
+ import { isTransactionUnavailable } from "../../core/dispatch.js";
16
+ import { CONTENDED_TX_MAX_WAIT_MS } from "../../core/timing.js";
14
17
  import { classifyProviderFailure, providerClassOfCategory, providerSentence, } from "../../core/provider-wording.js";
15
18
  import { logger } from "../../core/logger.js";
16
19
  import { requiredLevel } from "../../core/repo-access.js";
@@ -23,7 +26,10 @@ import { CODING_PROXY_ALIAS, CODING_PROXY_PORT, isImmutableDockerImage } from ".
23
26
  import { normalizeRegistryLockfiles } from "../../coding/registry/lockfiles.js";
24
27
  import { ISSUE_KEY, ISSUE_TRACKER_NAMES } from "../issue-tracker/types.js";
25
28
  import { HEARTBEAT_TIMEOUT_MS } from "../../core/timing.js";
29
+ import { collectRelatedPullRequests } from "../../core/related-pull-requests.js";
26
30
  const containerLog = logger.child({ module: "container-executor" });
31
+ /** The input path in a spec built only to ask the launcher about capacity; never written or read. */
32
+ const CAPACITY_PROBE_INPUT = "/dev/null";
27
33
  const TERMINAL_STATUSES = new Set(["succeeded", "failed", "refused", "lost", "budget_exhausted", "cancelled"]);
28
34
  const PROVISIONING_BACKEND = "provisioning";
29
35
  /** Serializes concurrency-slot claims across replicas. Distinct from the OAuth client lock (7412901). */
@@ -119,6 +125,7 @@ export class PrismaContainerExecutionStore {
119
125
  toolImage: row.codingRun.toolImage,
120
126
  workspaceDiskMb: row.codingRun.workspaceDiskMb,
121
127
  debugTrace: row.codingRun.debugTrace,
128
+ maxTurns: row.codingRun.maxTurns,
122
129
  services: row.codingRun.services,
123
130
  failureCategory: row.codingRun.failureCategory,
124
131
  profileRepository: row.agent.codingProfile?.repository ?? null,
@@ -127,6 +134,15 @@ export class PrismaContainerExecutionStore {
127
134
  pricingSnapshot: row.pricingSnapshot,
128
135
  };
129
136
  }
137
+ async relatedPullRequests(runId) {
138
+ const group = await collectRelatedPullRequests(this.db, runId);
139
+ // Every PR of the request so far, merged/closed ones included (listed as context).
140
+ return group.pullRequests.map((pr) => ({
141
+ repository: pr.repository,
142
+ number: pr.number,
143
+ ...(pr.state ? { state: pr.state } : {}),
144
+ }));
145
+ }
130
146
  async claimProvisioning(runId, claimId) {
131
147
  try {
132
148
  return await this.db.$transaction(async (tx) => {
@@ -195,14 +211,27 @@ export class PrismaContainerExecutionStore {
195
211
  throw new RunNoLongerActiveError();
196
212
  }
197
213
  return "claimed";
198
- });
214
+ }, { maxWait: CONTENDED_TX_MAX_WAIT_MS });
199
215
  }
200
216
  catch (err) {
201
217
  if (err instanceof RunNoLongerActiveError)
202
218
  return "unavailable";
219
+ // A burst of claims queued on the slot lock left no connection in time.
220
+ // Nothing was claimed: wait in the coding queue (drainCodingQueue starts
221
+ // it, oldest first) instead of failing a run that only needed to wait.
222
+ if (isTransactionUnavailable(err)) {
223
+ await this.markQueued(runId);
224
+ return "queued";
225
+ }
203
226
  throw err;
204
227
  }
205
228
  }
229
+ async markQueued(runId) {
230
+ await this.db.codingRun.updateMany({
231
+ where: { runId, jobBackend: null, jobHandle: null, queuedAt: null, run: { status: "pending" } },
232
+ data: { queuedAt: new Date() },
233
+ });
234
+ }
206
235
  async persistHandle(runId, claimId, handle) {
207
236
  await this.db.$transaction(async (tx) => {
208
237
  const coding = await tx.codingRun.findUnique({ where: { runId }, include: { run: true } });
@@ -236,7 +265,7 @@ export class PrismaContainerExecutionStore {
236
265
  data: { heartbeatAt: new Date() },
237
266
  });
238
267
  }
239
- async complete(runId, status, result) {
268
+ async complete(runId, status, result, record = {}) {
240
269
  const finishedAt = new Date();
241
270
  const finished = await this.db.$transaction(async (tx) => {
242
271
  const run = await tx.run.findUnique({ where: { id: runId }, include: { codingRun: true } });
@@ -249,7 +278,12 @@ export class PrismaContainerExecutionStore {
249
278
  }
250
279
  await tx.codingRun.update({
251
280
  where: { runId },
252
- data: { result, resultSchema: CODING_PROTOCOL_VERSION },
281
+ data: {
282
+ result,
283
+ resultSchema: CODING_PROTOCOL_VERSION,
284
+ ...(record.resultBranch ? { resultBranch: record.resultBranch } : {}),
285
+ ...(record.baseSha ? { baseSha: record.baseSha } : {}),
286
+ },
253
287
  });
254
288
  await tx.run.update({
255
289
  where: { id: runId },
@@ -328,7 +362,7 @@ export class ContainerExecutor {
328
362
  this.artifactRoot = resolve(options.artifactRoot);
329
363
  if (this.artifactRoot === resolve("/"))
330
364
  throw new Error("coding_artifact_root_invalid");
331
- if (!isImmutableDockerImage(options.workerImage)) {
365
+ if (options.workerImage !== undefined && !isImmutableDockerImage(options.workerImage)) {
332
366
  throw new Error("coding_worker_image_invalid");
333
367
  }
334
368
  for (const versions of Object.values(options.additionalWorkerImages ?? {})) {
@@ -464,6 +498,10 @@ export class ContainerExecutor {
464
498
  if (!handle) {
465
499
  if (run.provisioningClaim)
466
500
  return;
501
+ // No room in the cluster yet (the namespace quota): wait in the queue rather than claim a
502
+ // slot and then fail when the API server refuses the job.
503
+ if (!(await this.fitsCluster(run)))
504
+ return;
467
505
  claimId = randomUUID();
468
506
  // "queued": every slot is taken; the run stays pending and
469
507
  // drainCodingQueue starts it when one frees. "unavailable": another
@@ -665,6 +703,28 @@ export class ContainerExecutor {
665
703
  },
666
704
  };
667
705
  }
706
+ /** The new PR's Related pull requests input: earlier PRs of the same request, then this one. Never throws. */
707
+ async relatedFor(run) {
708
+ // A continuation pushes onto an existing PR, whose body is never rewritten here.
709
+ if (run.rootCodingRunId || !this.options.store.relatedPullRequests)
710
+ return {};
711
+ try {
712
+ const earlier = await this.options.store.relatedPullRequests(run.runId);
713
+ if (earlier.length === 0)
714
+ return {};
715
+ const { issue } = this.issueFor(run);
716
+ return {
717
+ related: {
718
+ entries: [...earlier, { repository: run.repository, self: true }],
719
+ ...(issue ? { issue } : {}),
720
+ },
721
+ };
722
+ }
723
+ catch (err) {
724
+ containerLog.warn({ err, runId: run.runId }, "could not list the request's related pull requests");
725
+ return {};
726
+ }
727
+ }
668
728
  /** The run's stored model terms; a run from before the catalog resolves them from the current catalog. */
669
729
  modelTerms(run) {
670
730
  if (run.provider !== "codex" && run.provider !== "claude-code")
@@ -810,10 +870,18 @@ export class ContainerExecutor {
810
870
  tag: output.tag,
811
871
  ...report,
812
872
  ...this.issueFor(current),
873
+ ...(await this.relatedFor(current)),
813
874
  });
875
+ // A pushed branch (local repository) is a successful run with no pull
876
+ // request; its branch is recorded on the run for the operator to merge.
814
877
  const result = this.resultFor(output, current, finalized.outcome, finalized);
815
- await this.options.store.complete(run.runId, "succeeded", result);
816
- if (finalized.outcome === "pull_request_opened" || finalized.outcome === "pull_request_updated") {
878
+ await this.options.store.complete(run.runId, "succeeded", result, {
879
+ baseSha: finalized.baseCommit,
880
+ ...(finalized.outcome === "branch_pushed" ? { resultBranch: finalized.headRef } : {}),
881
+ });
882
+ if (finalized.outcome === "pull_request_opened" ||
883
+ finalized.outcome === "pull_request_updated" ||
884
+ finalized.outcome === "branch_pushed") {
817
885
  this.emit({ stage: finalized.outcome, runId: run.runId, jobId: handle.id });
818
886
  }
819
887
  this.terminal(current, "succeeded");
@@ -886,7 +954,9 @@ export class ContainerExecutor {
886
954
  pullRequestUrl: finalized.pullRequestUrl,
887
955
  pullRequestNumber: finalized.pullRequestNumber,
888
956
  }
889
- : {}),
957
+ : outcome === "branch_pushed" && finalized?.outcome === "branch_pushed"
958
+ ? { headRef: finalized.headRef, commitSha: finalized.commitSha }
959
+ : {}),
890
960
  summary: output.summary,
891
961
  tests: output.tests,
892
962
  tag: output.tag,
@@ -911,8 +981,11 @@ export class ContainerExecutor {
911
981
  throw new Error("coding_worker_image_invalid");
912
982
  return selector.workerImageRef;
913
983
  }
914
- if (selector.toolchain === "node")
984
+ if (selector.toolchain === "node") {
985
+ if (!this.options.workerImage)
986
+ throw new Error("coding_provider_not_configured:codex");
915
987
  return this.options.workerImage;
988
+ }
916
989
  const versions = this.options.additionalWorkerImages?.[selector.toolchain];
917
990
  const image = selector.toolchainVersion ? versions?.[selector.toolchainVersion] : undefined;
918
991
  if (!image) {
@@ -971,11 +1044,31 @@ export class ContainerExecutor {
971
1044
  throw new Error("coding_run_not_found");
972
1045
  return current;
973
1046
  }
1047
+ /**
1048
+ * False when the launcher reports this run's job would not fit the cluster right now; the run
1049
+ * is then queued and drainCodingQueue retries it. Launchers and stores without the hooks, and
1050
+ * any error, answer true: the launch stays the authority.
1051
+ */
1052
+ async fitsCluster(run) {
1053
+ const { jobs, store } = this.options;
1054
+ if (!jobs.hasCapacityFor || !store.markQueued)
1055
+ return true;
1056
+ const fits = await jobs.hasCapacityFor(this.jobSpec(run, CAPACITY_PROBE_INPUT)).catch(() => true);
1057
+ if (fits)
1058
+ return true;
1059
+ await store.markQueued(run.runId);
1060
+ this.emit({ stage: "queued", runId: run.runId });
1061
+ return false;
1062
+ }
974
1063
  jobSpec(run, inputArtifact) {
975
1064
  const provider = run.provider === "claude-code" ? "claude-code" : "codex";
976
1065
  if (provider === "claude-code" && (!run.workerImage || !this.options.claudeToolRunnerImage)) {
977
1066
  throw new Error("coding_provider_not_configured:claude-code");
978
1067
  }
1068
+ // Runs keep the image they were dispatched with; one without it needs the deployment default.
1069
+ const image = run.workerImage ?? this.options.workerImage;
1070
+ if (!image)
1071
+ throw new Error("coding_provider_not_configured:codex");
979
1072
  if (run.workspaceDiskMb && run.workspaceDiskMb > this.options.maxDiskMb) {
980
1073
  throw new Error("coding_workspace_disk_exceeds_limit");
981
1074
  }
@@ -988,7 +1081,7 @@ export class ContainerExecutor {
988
1081
  kind: "coding-agent",
989
1082
  runId: run.runId,
990
1083
  provider,
991
- image: run.workerImage ?? this.options.workerImage,
1084
+ image,
992
1085
  // A run keeps the tool image it was dispatched with; rows from before CodingRun.toolImage use the default.
993
1086
  ...(provider === "claude-code" ? { toolImage: run.toolImage ?? this.options.claudeToolRunnerImage } : {}),
994
1087
  inputArtifact,
@@ -1035,6 +1128,8 @@ export class ContainerExecutor {
1035
1128
  continuationOf: run.rootCodingRunId ? { runId: run.rootCodingRunId } : undefined,
1036
1129
  // Only when true: an untraced run's input stays exactly what older workers expect.
1037
1130
  ...(run.debugTrace ? { debugTrace: true } : {}),
1131
+ // Only when the agent sets one: every other run's input stays exactly what older workers expect.
1132
+ ...(run.maxTurns ? { maxTurns: run.maxTurns } : {}),
1038
1133
  // Only when there are some: every other run's input stays exactly what older workers expect.
1039
1134
  ...(services.length > 0 ? { services: workerServices(services) } : {}),
1040
1135
  });
@@ -1248,6 +1343,8 @@ function safeError(error) {
1248
1343
  * "workspace" because "github" contains "git".
1249
1344
  */
1250
1345
  const CATEGORY_BY_PREFIX = [
1346
+ // Claude Code reached the agent's turn limit (codingProfile.maxTurns) before finishing.
1347
+ ["job_coding_turn_limit", "turn_limit"],
1251
1348
  // A service sidecar never became ready (src/providers/jobs/kubernetes.ts).
1252
1349
  [SERVICE_UNREADY_ERROR, SERVICE_UNREADY_CATEGORY],
1253
1350
  // Services on a launcher that can't start them: refused at dispatch, failed here as a backstop.
@@ -1262,15 +1359,49 @@ const CATEGORY_BY_PREFIX = [
1262
1359
  // Checked before the generic "vcs_" fallback: the collected diff touched a
1263
1360
  // path this agent may not edit, not a generic workspace/git failure.
1264
1361
  ["vcs_protected_path:", PROTECTED_PATH_CATEGORY],
1362
+ // A continuation whose pull request was merged or closed: nothing pushed (coding/continuation-wording.ts).
1363
+ [CONTINUATION_CLOSED_ERROR, CONTINUATION_CLOSED_CATEGORY],
1364
+ // A provider whose worker images this deployment doesn't configure (codex or claude-code): configuration.
1365
+ ["coding_provider_not_configured", "preflight"],
1366
+ // A GitHub repository on a server with no GitHub App: configuration, like an unsupported service.
1367
+ ["vcs_github_not_configured", "preflight"],
1265
1368
  ["vcs_", "workspace"],
1369
+ // Local repositories (LocalRepoError codes): the same family as the vcs_ errors.
1370
+ ["local_", "workspace"],
1266
1371
  ["git_", "workspace"],
1267
1372
  ];
1373
+ /**
1374
+ * Whether `error` or something in its `cause` chain (bounded depth, in case a
1375
+ * cycle somehow formed) is -- or wraps -- an error whose own message starts
1376
+ * with `prefix`.
1377
+ */
1378
+ function causeChainStartsWith(error, prefix) {
1379
+ let current = error;
1380
+ for (let depth = 0; depth < 5 && current; depth += 1) {
1381
+ const raw = current instanceof Error ? current.message : typeof current === "string" ? current : "";
1382
+ if (raw.startsWith(prefix))
1383
+ return true;
1384
+ current = current instanceof Error ? current.cause : undefined;
1385
+ }
1386
+ return false;
1387
+ }
1268
1388
  function failureCategory(error) {
1269
1389
  const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
1270
1390
  for (const [prefix, category] of CATEGORY_BY_PREFIX) {
1271
1391
  if (raw.startsWith(prefix))
1272
1392
  return category;
1273
1393
  }
1394
+ // A continuation whose pull request is no longer open can fail before a
1395
+ // session or job exists (assertContinuationOpen, called at the top of
1396
+ // prepareWorkspace) -- that failure is re-thrown as a PreflightError whose
1397
+ // own message is `safeError(error)`, a sanitized wrapper that no longer
1398
+ // starts with CONTINUATION_CLOSED_ERROR (see the `!spendEnabled && !handle`
1399
+ // branch above). The real, un-wrapped error survives on `cause`. Only this
1400
+ // one category is looked up through the cause chain this way -- every
1401
+ // other prefix above keeps matching only the top-level message, exactly as
1402
+ // before, so no other prepare-time failure's category changes here.
1403
+ if (causeChainStartsWith(error, CONTINUATION_CLOSED_ERROR))
1404
+ return CONTINUATION_CLOSED_CATEGORY;
1274
1405
  const message = safeError(error);
1275
1406
  if (message.includes("preflight") || message.includes("ownership") || message.includes("image"))
1276
1407
  return "preflight";
@@ -70,7 +70,7 @@ export interface Executor {
70
70
  }): Promise<string | null>;
71
71
  /**
72
72
  * The raw text of one repository file at `baseRef`, or null when absent.
73
- * Throws (github_file_too_large, github_file_not_a_file, github_file_not_utf8)
73
+ * Throws (github_file_* or local_file_*: too_large, not_a_file, not_utf8)
74
74
  * when the file is oversized or not UTF-8 text. Called by dispatch before its transaction (network call).
75
75
  * Used for the knowledge note (src/knowledge/note.ts).
76
76
  */
@@ -1,4 +1,4 @@
1
- import type { V1ConfigMap, V1Endpoints, V1NetworkPolicy, V1Pod, V1Secret, V1Service } from "@kubernetes/client-node";
1
+ import type { V1ConfigMap, V1Endpoints, V1NetworkPolicy, V1Pod, V1ResourceQuota, V1Secret, V1Service } from "@kubernetes/client-node";
2
2
  import type { Readable, Writable } from "node:stream";
3
3
  import { type KubernetesApi, type KubernetesExecOptions } from "./kubernetes-api.js";
4
4
  export interface FakeExecCall {
@@ -10,7 +10,7 @@ export interface FakeExecCall {
10
10
  stdout?: Writable;
11
11
  timeoutMs: number;
12
12
  }
13
- type Kind = "configmap" | "secret" | "pod" | "networkpolicy" | "service" | "endpoints";
13
+ type Kind = "configmap" | "secret" | "pod" | "networkpolicy" | "service" | "endpoints" | "resourcequota";
14
14
  type Obj = {
15
15
  metadata?: {
16
16
  name?: string;
@@ -91,6 +91,7 @@ export declare class FakeKubernetesApi implements KubernetesApi {
91
91
  deleteNetworkPolicy(namespace: string, name: string): Promise<void>;
92
92
  readService(namespace: string, name: string): Promise<V1Service | undefined>;
93
93
  readEndpoints(namespace: string, name: string): Promise<V1Endpoints | undefined>;
94
+ readResourceQuota(namespace: string, name: string): Promise<V1ResourceQuota | undefined>;
94
95
  readApiServerVersion(): Promise<string>;
95
96
  readNamespace(name: string): Promise<boolean>;
96
97
  exec(namespace: string, pod: string, container: string, command: string[], options: KubernetesExecOptions): Promise<number>;
@@ -114,6 +114,9 @@ export class FakeKubernetesApi {
114
114
  async readEndpoints(namespace, name) {
115
115
  return this.read("endpoints", namespace, name);
116
116
  }
117
+ async readResourceQuota(namespace, name) {
118
+ return this.read("resourcequota", namespace, name);
119
+ }
117
120
  async readApiServerVersion() {
118
121
  return this.apiServerVersion;
119
122
  }
@@ -3,7 +3,7 @@
3
3
  * so every launcher behavior is unit-tested against FakeKubernetesApi and
4
4
  * re-proven against a real cluster by kubernetes.integration.test.ts.
5
5
  */
6
- import type { V1ConfigMap, V1Endpoints, V1NetworkPolicy, V1Pod, V1Secret, V1Service } from "@kubernetes/client-node";
6
+ import type { V1ConfigMap, V1Endpoints, V1NetworkPolicy, V1Pod, V1ResourceQuota, V1Secret, V1Service } from "@kubernetes/client-node";
7
7
  import type { Readable, Writable } from "node:stream";
8
8
  export declare class KubernetesNotFoundError extends Error {
9
9
  constructor(message?: string);
@@ -46,6 +46,8 @@ export interface KubernetesApi {
46
46
  * resource (migrating to it is a follow-up); Endpoints is read by name so RBAC stays one object wide.
47
47
  */
48
48
  readEndpoints(namespace: string, name: string): Promise<V1Endpoints | undefined>;
49
+ /** Reads one ResourceQuota by name (its status carries hard and used), for the launcher's capacity check. */
50
+ readResourceQuota(namespace: string, name: string): Promise<V1ResourceQuota | undefined>;
49
51
  readNamespace(name: string): Promise<boolean>;
50
52
  /** Runs a command in a container; resolves with its exit code. Never uses a shell. */
51
53
  exec(namespace: string, pod: string, container: string, command: string[], options: KubernetesExecOptions): Promise<number>;
@@ -3,7 +3,7 @@
3
3
  * method is one client call plus HTTP-status translation into the seam's
4
4
  * error classes. Proven against a real cluster by kubernetes.integration.test.ts.
5
5
  */
6
- import { type V1ConfigMap, type V1Endpoints, type V1NetworkPolicy, type V1Pod, type V1Secret, type V1Service } from "@kubernetes/client-node";
6
+ import { type V1ConfigMap, type V1Endpoints, type V1ResourceQuota, type V1NetworkPolicy, type V1Pod, type V1Secret, type V1Service } from "@kubernetes/client-node";
7
7
  import { type KubernetesApi, type KubernetesExecOptions } from "./kubernetes-api.js";
8
8
  export interface ClientNodeKubernetesApiOptions {
9
9
  /** kubeconfig context to use; defaults to the current context (or in-cluster credentials). */
@@ -29,6 +29,7 @@ export declare class ClientNodeKubernetesApi implements KubernetesApi {
29
29
  deleteNetworkPolicy(namespace: string, name: string): Promise<void>;
30
30
  readService(namespace: string, name: string): Promise<V1Service | undefined>;
31
31
  readEndpoints(namespace: string, name: string): Promise<V1Endpoints | undefined>;
32
+ readResourceQuota(namespace: string, name: string): Promise<V1ResourceQuota | undefined>;
32
33
  readApiServerVersion(): Promise<string>;
33
34
  readNamespace(name: string): Promise<boolean>;
34
35
  exec(namespace: string, pod: string, container: string, command: string[], options: KubernetesExecOptions): Promise<number>;
@@ -121,6 +121,9 @@ export class ClientNodeKubernetesApi {
121
121
  readEndpoints(namespace, name) {
122
122
  return readOrUndefined(() => this.core.readNamespacedEndpoints({ namespace, name }));
123
123
  }
124
+ readResourceQuota(namespace, name) {
125
+ return readOrUndefined(() => this.core.readNamespacedResourceQuota({ namespace, name }));
126
+ }
124
127
  async readApiServerVersion() {
125
128
  return (await this.version.getCode()).gitVersion;
126
129
  }
@@ -33,6 +33,13 @@ export declare const PROXY_POD_LABEL: {
33
33
  export declare const RUN_COMPONENT_LABEL: {
34
34
  readonly "wardby.io/component": "coding-run";
35
35
  };
36
+ /** Kubernetes managed-by label on every run pod. */
37
+ export declare const RUN_MANAGED_BY_LABEL: {
38
+ readonly "app.kubernetes.io/managed-by": "wardby";
39
+ };
40
+ /** Label on every coding-run pod/Job: the first 40 hex chars of sha256(runId). */
41
+ export declare const RUN_SHA_LABEL = "wardby.io/run-sha256";
42
+ export declare const RUN_SHA_CHARS = 40;
36
43
  /** The probe proved enforcement: the proxy port connected and the deny port was blocked. */
37
44
  export declare const ENFORCEMENT_PROBE_PROVEN = 0;
38
45
  /** The deny port was reachable: no policy is blocking it, or the policy is not port-scoped. */
@@ -42,6 +42,11 @@ export const PROXY_POD_LABEL = { "app.kubernetes.io/name": "wardby-coding-proxy"
42
42
  * Single source: `runLabels` stamps it, `readProxyWitness` checks the proxy admits it.
43
43
  */
44
44
  export const RUN_COMPONENT_LABEL = { "wardby.io/component": "coding-run" };
45
+ /** Kubernetes managed-by label on every run pod. */
46
+ export const RUN_MANAGED_BY_LABEL = { "app.kubernetes.io/managed-by": "wardby" };
47
+ /** Label on every coding-run pod/Job: the first 40 hex chars of sha256(runId). */
48
+ export const RUN_SHA_LABEL = "wardby.io/run-sha256";
49
+ export const RUN_SHA_CHARS = 40;
45
50
  /** The probe proved enforcement: the proxy port connected and the deny port was blocked. */
46
51
  export const ENFORCEMENT_PROBE_PROVEN = 0;
47
52
  /** The deny port was reachable: no policy is blocking it, or the policy is not port-scoped. */
@@ -120,13 +125,13 @@ export function kubernetesRunNames(runId) {
120
125
  if (!RUN_ID.test(runId))
121
126
  throw isolationError();
122
127
  const digest = sha256(runId);
123
- return { ...kubernetesRunNamesForToken(digest.slice(0, 20)), runSha: digest.slice(0, 40) };
128
+ return { ...kubernetesRunNamesForToken(digest.slice(0, 20)), runSha: digest.slice(0, RUN_SHA_CHARS) };
124
129
  }
125
130
  export function runLabels(runId) {
126
131
  return {
127
- "app.kubernetes.io/managed-by": "wardby",
132
+ ...RUN_MANAGED_BY_LABEL,
128
133
  ...RUN_COMPONENT_LABEL,
129
- "wardby.io/run-sha256": kubernetesRunNames(runId).runSha,
134
+ [RUN_SHA_LABEL]: kubernetesRunNames(runId).runSha,
130
135
  };
131
136
  }
132
137
  /** Same grammar as Docker's repository digests; a cluster cannot pull a bare local image ID. */
@@ -11,6 +11,14 @@ export interface KubernetesPreflightOptions {
11
11
  timeoutMs?: number;
12
12
  cleanupTimeoutMs?: number;
13
13
  }
14
+ /**
15
+ * The image the preflight canary runs (it only needs node): the Codex worker when configured,
16
+ * else the Claude Code worker, so a Claude-only deployment is checked too.
17
+ */
18
+ export declare function preflightCanaryImage(images: {
19
+ workerImage?: string;
20
+ claudeWorkerImage?: string;
21
+ }): string | undefined;
14
22
  export interface CanaryResult {
15
23
  dns: boolean;
16
24
  /** The proxy's deny port: reachable means the run policy is not being enforced. */
@@ -12,6 +12,13 @@ import { CODING_PROXY_DENY_PORT, CODING_PROXY_PORT } from "./docker-isolation.js
12
12
  import { KUBERNETES_ISOLATION_ERROR, WORKER_CONTAINER, buildRunNetworkPolicy, buildRunPod, isRegistryDigest, } from "./kubernetes-isolation.js";
13
13
  import { KubernetesPlatformError, assertPlatformConfig, platformProfile } from "./kubernetes-platform.js";
14
14
  import { readProxyWitness } from "./kubernetes-witness.js";
15
+ /**
16
+ * The image the preflight canary runs (it only needs node): the Codex worker when configured,
17
+ * else the Claude Code worker, so a Claude-only deployment is checked too.
18
+ */
19
+ export function preflightCanaryImage(images) {
20
+ return images.workerImage || images.claudeWorkerImage || undefined;
21
+ }
15
22
  const DEFAULT_TIMEOUT_MS = 90_000;
16
23
  const DEFAULT_CLEANUP_TIMEOUT_MS = 15_000;
17
24
  const POLL_INTERVAL_MS = 500;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Whether one more coding-run pod fits a namespace ResourceQuota (KUBERNETES_RESOURCE_QUOTA).
3
+ * The launcher asks before a run claims a slot, so a run that would be refused at pod create
4
+ * ("exceeded quota") waits in the coding queue instead of failing.
5
+ */
6
+ import type { V1Pod, V1ResourceQuota } from "@kubernetes/client-node";
7
+ /** What a pod adds to a quota: millicores, bytes, and one pod. */
8
+ export interface PodQuotaUsage {
9
+ "requests.cpu": number;
10
+ "limits.cpu": number;
11
+ "requests.memory": number;
12
+ "limits.memory": number;
13
+ pods: number;
14
+ }
15
+ /** A Kubernetes CPU quantity ("250m", "1.5", "8") in millicores, or null when unparseable. */
16
+ export declare function cpuMillicores(quantity: string | number | undefined): number | null;
17
+ /** A Kubernetes memory quantity ("512Mi", "1G", "1048576") in bytes, or null when unparseable. */
18
+ export declare function memoryBytes(quantity: string | number | undefined): number | null;
19
+ /**
20
+ * The pod's effective requests and limits, as quota admission counts them: its containers plus
21
+ * native sidecars (init containers with restartPolicy Always), or its largest ordinary init
22
+ * container if that is bigger. Close enough for an admission pre-check; the API server stays the
23
+ * authority.
24
+ */
25
+ export declare function podQuotaUsage(pod: V1Pod): PodQuotaUsage;
26
+ /**
27
+ * The first quota resource the pod would exceed (for example "requests.cpu"), or null when it
28
+ * fits. A quota with no status yet, or a value this can't parse, never blocks: the API server
29
+ * still has the final word at create.
30
+ */
31
+ export declare function quotaShortfall(quota: V1ResourceQuota, usage: PodQuotaUsage): string | null;
@@ -0,0 +1,110 @@
1
+ const BINARY = { Ki: 2 ** 10, Mi: 2 ** 20, Gi: 2 ** 30, Ti: 2 ** 40, Pi: 2 ** 50 };
2
+ const DECIMAL = { k: 1e3, M: 1e6, G: 1e9, T: 1e12, P: 1e15 };
3
+ const QUANTITY = /^([0-9]+(?:\.[0-9]+)?)(m|Ki|Mi|Gi|Ti|Pi|k|M|G|T|P)?$/;
4
+ /** A Kubernetes CPU quantity ("250m", "1.5", "8") in millicores, or null when unparseable. */
5
+ export function cpuMillicores(quantity) {
6
+ if (quantity === undefined)
7
+ return 0;
8
+ const match = QUANTITY.exec(String(quantity).trim());
9
+ if (!match)
10
+ return null;
11
+ const value = Number(match[1]);
12
+ if (match[2] === "m")
13
+ return value;
14
+ if (match[2] !== undefined)
15
+ return null;
16
+ return value * 1000;
17
+ }
18
+ /** A Kubernetes memory quantity ("512Mi", "1G", "1048576") in bytes, or null when unparseable. */
19
+ export function memoryBytes(quantity) {
20
+ if (quantity === undefined)
21
+ return 0;
22
+ const match = QUANTITY.exec(String(quantity).trim());
23
+ if (!match)
24
+ return null;
25
+ const value = Number(match[1]);
26
+ const unit = match[2];
27
+ if (unit === undefined)
28
+ return value;
29
+ if (unit === "m")
30
+ return value / 1000;
31
+ return value * (BINARY[unit] ?? DECIMAL[unit] ?? NaN);
32
+ }
33
+ function sum(containers) {
34
+ const total = {
35
+ "requests.cpu": 0,
36
+ "limits.cpu": 0,
37
+ "requests.memory": 0,
38
+ "limits.memory": 0,
39
+ pods: 1,
40
+ };
41
+ for (const container of containers) {
42
+ const requests = container.resources?.requests ?? {};
43
+ const limits = container.resources?.limits ?? {};
44
+ total["requests.cpu"] += cpuMillicores(requests.cpu) ?? 0;
45
+ total["limits.cpu"] += cpuMillicores(limits.cpu) ?? 0;
46
+ total["requests.memory"] += memoryBytes(requests.memory) ?? 0;
47
+ total["limits.memory"] += memoryBytes(limits.memory) ?? 0;
48
+ }
49
+ return total;
50
+ }
51
+ /**
52
+ * The pod's effective requests and limits, as quota admission counts them: its containers plus
53
+ * native sidecars (init containers with restartPolicy Always), or its largest ordinary init
54
+ * container if that is bigger. Close enough for an admission pre-check; the API server stays the
55
+ * authority.
56
+ */
57
+ export function podQuotaUsage(pod) {
58
+ const init = pod.spec?.initContainers ?? [];
59
+ const sidecars = init.filter((container) => container.restartPolicy === "Always");
60
+ const running = sum([...(pod.spec?.containers ?? []), ...sidecars]);
61
+ for (const container of init.filter((c) => c.restartPolicy !== "Always")) {
62
+ const one = sum([container]);
63
+ for (const key of ["requests.cpu", "limits.cpu", "requests.memory", "limits.memory"]) {
64
+ running[key] = Math.max(running[key], one[key]);
65
+ }
66
+ }
67
+ return running;
68
+ }
69
+ /** Quota keys and the aliases Kubernetes accepts for them ("cpu" means "requests.cpu"). */
70
+ const KEYS = [
71
+ ["requests.cpu", ["requests.cpu", "cpu"], "cpu"],
72
+ ["limits.cpu", ["limits.cpu"], "cpu"],
73
+ ["requests.memory", ["requests.memory", "memory"], "memory"],
74
+ ["limits.memory", ["limits.memory"], "memory"],
75
+ ["pods", ["pods", "count/pods"], "count"],
76
+ ];
77
+ function parse(kind, value) {
78
+ if (kind === "cpu")
79
+ return cpuMillicores(value);
80
+ if (kind === "memory")
81
+ return memoryBytes(value);
82
+ if (value === undefined)
83
+ return 0;
84
+ const count = Number(value);
85
+ return Number.isFinite(count) ? count : null;
86
+ }
87
+ /**
88
+ * The first quota resource the pod would exceed (for example "requests.cpu"), or null when it
89
+ * fits. A quota with no status yet, or a value this can't parse, never blocks: the API server
90
+ * still has the final word at create.
91
+ */
92
+ export function quotaShortfall(quota, usage) {
93
+ const hard = quota.status?.hard;
94
+ if (!hard)
95
+ return null;
96
+ const used = quota.status?.used ?? {};
97
+ for (const [field, names, kind] of KEYS) {
98
+ for (const name of names) {
99
+ if (hard[name] === undefined)
100
+ continue;
101
+ const limit = parse(kind, hard[name]);
102
+ const current = parse(kind, used[name]);
103
+ if (limit === null || current === null)
104
+ continue;
105
+ if (current + usage[field] > limit)
106
+ return name;
107
+ }
108
+ }
109
+ return null;
110
+ }
@@ -73,6 +73,15 @@ export declare class KubernetesJobLauncher implements WorkspaceJobLauncher {
73
73
  * launcher itself has no `list` permission and so can never rediscover stray objects).
74
74
  */
75
75
  plannedHandle(spec: JobSpec): JobHandle | undefined;
76
+ /**
77
+ * Whether this spec's pod fits the free room in the namespace's ResourceQuota
78
+ * (KubernetesJobConfig.resourceQuota), counted from the same pod `launch` would create. Any
79
+ * doubt (no quota configured, unreadable, unparseable, a spec the pod builder rejects) answers
80
+ * yes, so the launch and the API server stay the authority.
81
+ */
82
+ hasCapacityFor(spec: JobSpec): Promise<boolean>;
83
+ private readonly warnedOnce;
84
+ private warnOnce;
76
85
  launch(spec: JobSpec): Promise<JobHandle>;
77
86
  status(handle: JobHandle): Promise<JobStatus>;
78
87
  collect(handle: JobHandle): Promise<JobResult>;