world-model-optimizer 0.2.0__py3-none-any.whl

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 (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,587 @@
1
+ """Harbor E2B task environment with safe template builds and paced sandbox creates.
2
+
3
+ `WmoE2BEnvironment` subclasses harbor's E2B backend to fix three operational hazards, keeping
4
+ everything else (mounts, network policy, uploads, verification) harbor's:
5
+
6
+ - **Resource-qualified template aliases.** Harbor names templates by environment content only,
7
+ so tasks sharing content but differing in cpu/memory collide. The alias here embeds the full
8
+ resource identity (see `e2b_template_policy`), byte-compatible with the templates already
9
+ built on the account.
10
+ - **Single-submit builds.** Harbor wraps its combined submit-and-wait `AsyncTemplate.build` in
11
+ a tenacity retry (harbor/environments/e2b.py `_create_template`), so one transport blip during
12
+ a long build replays the submission and pays for a duplicate build. This class submits exactly
13
+ once via `build_in_background`, then polls the idempotent `get_build_status` GET, retrying
14
+ ONLY transport/rate-limit errors there. A per-loop per-alias single-flight lock plus a
15
+ process-wide submitted-build registry stop concurrent trials AND concurrent scorer loops from
16
+ both seeing "missing" and double-submitting (followers poll the already-paid build), and a
17
+ process-wide bounded semaphore keeps concurrent builds under the account limit.
18
+ - **Create pacing.** Every sandbox create routes through the same process-wide 4/sec admission
19
+ gate as wmo's own pi-worker sandboxes, so the two consumers cannot jointly exceed E2B's
20
+ published account rate.
21
+
22
+ It also owns this backend's sandbox lifecycle hygiene. Every created sandbox is appended to the
23
+ `wmo.harness.e2b_ledger` record the moment E2B returns it and released when its kill is proved,
24
+ so a run that dies without graceful shutdown leaves its sandboxes reapable by exact id
25
+ (`wmo e2b reap`) instead of holding account concurrency slots for their full multi-hour timeout.
26
+ Creates that hit the account's concurrent-sandbox cap (a 429) wait and retry on a bounded
27
+ schedule, so a transient spike costs latency rather than the trial.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import logging
34
+ import re
35
+ import threading
36
+ import weakref
37
+ from collections.abc import Sequence
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Literal, override
41
+
42
+ import httpx
43
+ from e2b import AsyncSandbox, AsyncTemplate, Template
44
+ from e2b.exceptions import BuildException, RateLimitException
45
+ from e2b.template.main import TemplateClass
46
+ from e2b.template.types import BuildInfo, TemplateBuildStatus, TemplateBuildStatusResponse
47
+ from harbor.environments.e2b import E2BEnvironment
48
+ from harbor.models.task.config import (
49
+ EnvironmentConfig,
50
+ NetworkMode,
51
+ NetworkPolicy,
52
+ TpuSpec,
53
+ )
54
+ from harbor.models.trial.config import ResourceMode, ServiceVolumeConfig
55
+ from harbor.models.trial.paths import TrialPaths
56
+
57
+ from wmo.evals.harbor.e2b_template_policy import (
58
+ E2B_TEMPLATE_BUILD_CONCURRENCY,
59
+ E2B_TEMPLATE_BUILD_STATUS_POLL_INTERVAL_MS,
60
+ E2B_TEMPLATE_BUILD_STATUS_RETRY_DELAYS_MS,
61
+ E2BTemplateResources,
62
+ qualify_harbor_e2b_template_name,
63
+ resolve_e2b_template_resources,
64
+ )
65
+ from wmo.harness.e2b_ledger import default_ledger, harbor_trial_name
66
+ from wmo.harness.e2b_sandbox import acquire_e2b_create_slot_async
67
+
68
+ # Harbor's own _create_sandbox contract: two attempts with a short pause. Replicated here so
69
+ # routing through the create gate does not change harbor's retry behavior.
70
+ _CREATE_ATTEMPTS = 2
71
+ _CREATE_RETRY_DELAY_S = 1.0
72
+ # Waits before each retry of a create that E2B rejected as retryable, which in practice means a
73
+ # 429 for the account's concurrent-sandbox cap. Slots free only as other trials finish, so the
74
+ # schedule is patient but bounded (~4 minutes total): a spike waits, while a genuinely full
75
+ # account still fails the trial in reasonable time instead of hanging the whole step.
76
+ _CREATE_RATE_LIMIT_DELAYS_S = (5.0, 10.0, 20.0, 40.0, 60.0, 60.0, 60.0)
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class _SubmittedBuild:
81
+ """The provider identity of one already-submitted template build."""
82
+
83
+ template_id: str
84
+ build_id: str
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class _AmbiguousSubmission:
89
+ """A submission whose outcome is unknown: it failed before the client got a build id.
90
+
91
+ E2B may have accepted the request (a paid build may be running) even though the client saw
92
+ an exception, so the alias must stay claimed; the next attempt reconciles with the control
93
+ plane before any resubmission.
94
+ """
95
+
96
+ error: str
97
+
98
+
99
+ # Build coordination happens at two levels.
100
+ #
101
+ # Per-loop, per-alias asyncio locks give in-loop single-flight: harbor's base start() does
102
+ # exists -> build, so without the lock two racing attempts of the same task both see "missing"
103
+ # and double-submit one paid build. They are keyed BY EVENT LOOP because each
104
+ # HarborScorer.score() runs its job under a fresh asyncio.run loop, and an asyncio primitive
105
+ # that gained waiters binds to its loop (Python 3.12); the entry dies with the loop.
106
+ #
107
+ # Process-wide, thread-safe state covers CONCURRENT loops (two scorer threads, two candidates):
108
+ # a registry of submitted build identities per alias (plain data, safe to share across loops)
109
+ # so a second loop polls the first loop's already-paid build instead of resubmitting, and one
110
+ # bounded semaphore capping concurrent builds account-wide regardless of how many loops exist.
111
+ # The threading guard only ever protects fast dict operations; nothing awaits under it.
112
+ _CONTROL_GUARD = threading.Lock()
113
+ _LOOP_ALIAS_LOCKS: weakref.WeakKeyDictionary[
114
+ asyncio.AbstractEventLoop,
115
+ dict[str, asyncio.Lock],
116
+ ] = weakref.WeakKeyDictionary()
117
+ # alias -> identity of the submitted build; None marks a submission in flight (claimed);
118
+ # _AmbiguousSubmission marks a failed submission with an unknown provider-side outcome.
119
+ _SUBMITTED_BUILDS: dict[str, _SubmittedBuild | _AmbiguousSubmission | None] = {}
120
+ _PROCESS_BUILD_SLOTS = threading.BoundedSemaphore(E2B_TEMPLATE_BUILD_CONCURRENCY)
121
+ _BUILD_SLOT_POLL_INTERVAL_S = 0.25
122
+ _REGISTRY_POLL_INTERVAL_S = 0.05
123
+ # Reconciling an ambiguous submission checks alias visibility over a bounded window because E2B
124
+ # alias visibility is eventually consistent: a build that E2B accepted can be invisible for a
125
+ # few seconds, so one False must not be read as "rejected" and trigger a duplicate paid build.
126
+ # ~6 checks x 5s covers the observed propagation lag with margin.
127
+ _AMBIGUOUS_VISIBILITY_CHECKS = 6
128
+ _AMBIGUOUS_VISIBILITY_DELAY_S = 5.0
129
+
130
+
131
+ def _template_lock(template_name: str) -> asyncio.Lock:
132
+ """The running loop's single-flight lock for `template_name`."""
133
+ loop = asyncio.get_running_loop()
134
+ with _CONTROL_GUARD:
135
+ locks = _LOOP_ALIAS_LOCKS.get(loop)
136
+ if locks is None:
137
+ locks = {}
138
+ _LOOP_ALIAS_LOCKS[loop] = locks
139
+ lock = locks.get(template_name)
140
+ if lock is None:
141
+ lock = asyncio.Lock()
142
+ locks[template_name] = lock
143
+ return lock
144
+
145
+
146
+ def _claim_build(
147
+ alias: str,
148
+ ) -> _SubmittedBuild | _AmbiguousSubmission | Literal["owner", "pending"]:
149
+ """Atomically claim the right to submit `alias`, or report what already happened to it.
150
+
151
+ An ambiguous entry is converted back into a live claim on return, so exactly one caller
152
+ owns its reconciliation while everyone else sees "pending".
153
+ """
154
+ with _CONTROL_GUARD:
155
+ if alias not in _SUBMITTED_BUILDS:
156
+ _SUBMITTED_BUILDS[alias] = None # claimed: this caller owns the submission
157
+ return "owner"
158
+ entry = _SUBMITTED_BUILDS[alias]
159
+ if isinstance(entry, _AmbiguousSubmission):
160
+ _SUBMITTED_BUILDS[alias] = None # claimed: this caller owns the reconciliation
161
+ return entry
162
+ return "pending" if entry is None else entry
163
+
164
+
165
+ def _record_submitted_build(alias: str, entry: _SubmittedBuild | _AmbiguousSubmission) -> None:
166
+ with _CONTROL_GUARD:
167
+ _SUBMITTED_BUILDS[alias] = entry
168
+
169
+
170
+ def _clear_submitted_build(alias: str, expected: _SubmittedBuild | None) -> None:
171
+ """Drop the registry entry (only if it still matches `expected`, when given)."""
172
+ with _CONTROL_GUARD:
173
+ if expected is None or _SUBMITTED_BUILDS.get(alias) == expected:
174
+ _SUBMITTED_BUILDS.pop(alias, None)
175
+
176
+
177
+ async def _acquire_build_slot() -> None:
178
+ """Take one process-wide build slot without blocking the event loop or a worker thread."""
179
+ while not _PROCESS_BUILD_SLOTS.acquire(blocking=False):
180
+ await asyncio.sleep(_BUILD_SLOT_POLL_INTERVAL_S)
181
+
182
+
183
+ # e2b converts a non-2xx status GET into BuildException(f"{status_code}: ...") (only 429 becomes
184
+ # RateLimitException, 401 AuthenticationException); a transient server-side 5xx is as retryable
185
+ # as a transport error, while 4xx and terminal build states stay fatal.
186
+ _SERVER_ERROR_BUILD_EXCEPTION = re.compile(r"^5\d\d: ")
187
+
188
+
189
+ def _is_retryable_status_error(error: Exception) -> bool:
190
+ if isinstance(error, (httpx.TransportError, RateLimitException)):
191
+ return True
192
+ return isinstance(error, BuildException) and bool(
193
+ _SERVER_ERROR_BUILD_EXCEPTION.match(str(error))
194
+ )
195
+
196
+
197
+ async def _get_template_build_status(
198
+ build_info: BuildInfo,
199
+ *,
200
+ logs_offset: int,
201
+ ) -> TemplateBuildStatusResponse:
202
+ """Retry only the idempotent GET for one already-submitted exact build."""
203
+ for attempt in range(len(E2B_TEMPLATE_BUILD_STATUS_RETRY_DELAYS_MS) + 1):
204
+ try:
205
+ return await AsyncTemplate.get_build_status(build_info, logs_offset=logs_offset)
206
+ except Exception as error: # noqa: BLE001 - classified below; non-transient re-raises
207
+ if not _is_retryable_status_error(error):
208
+ raise
209
+ if attempt == len(E2B_TEMPLATE_BUILD_STATUS_RETRY_DELAYS_MS):
210
+ raise
211
+ await asyncio.sleep(E2B_TEMPLATE_BUILD_STATUS_RETRY_DELAYS_MS[attempt] / 1_000)
212
+ raise AssertionError("unreachable template build status retry state")
213
+
214
+
215
+ async def _await_submitted_build(alias: str, submitted: _SubmittedBuild) -> None:
216
+ """Follower path: poll a build another loop already paid for; never submit."""
217
+ build_info = BuildInfo(
218
+ template_id=submitted.template_id,
219
+ build_id=submitted.build_id,
220
+ name=alias,
221
+ alias=alias,
222
+ )
223
+ try:
224
+ await _wait_for_template_build(build_info)
225
+ except BaseException:
226
+ # Clear only if the entry still names the failed build, so a fresh rebuild started by
227
+ # another caller is never clobbered.
228
+ _clear_submitted_build(alias, submitted)
229
+ raise
230
+
231
+
232
+ async def _wait_for_template_build(build_info: BuildInfo) -> None:
233
+ """Wait for one submitted build without ever replaying its submission."""
234
+ logs_offset = 0
235
+ while True:
236
+ response = await _get_template_build_status(build_info, logs_offset=logs_offset)
237
+ if (
238
+ response.template_id != build_info.template_id
239
+ or response.build_id != build_info.build_id
240
+ ):
241
+ raise RuntimeError("E2B template build status identity disagreement")
242
+ logs_offset += len(response.log_entries)
243
+ if response.status is TemplateBuildStatus.READY:
244
+ return
245
+ if response.status is TemplateBuildStatus.ERROR:
246
+ message = response.reason.message if response.reason else "E2B template build failed"
247
+ raise BuildException(message)
248
+ if response.status not in (
249
+ TemplateBuildStatus.BUILDING,
250
+ TemplateBuildStatus.WAITING,
251
+ ):
252
+ raise BuildException("E2B template build returned an unknown status")
253
+ await asyncio.sleep(E2B_TEMPLATE_BUILD_STATUS_POLL_INTERVAL_MS / 1_000)
254
+
255
+
256
+ class WmoE2BEnvironment(E2BEnvironment):
257
+ """Harbor's E2B environment with qualified aliases, safe builds, and paced creates."""
258
+
259
+ def __init__(
260
+ self,
261
+ environment_dir: Path,
262
+ environment_name: str,
263
+ session_id: str,
264
+ trial_paths: TrialPaths,
265
+ task_env_config: EnvironmentConfig,
266
+ logger: logging.Logger | None = None,
267
+ override_cpus: int | None = None,
268
+ override_memory_mb: int | None = None,
269
+ override_storage_mb: int | None = None,
270
+ override_gpus: int | None = None,
271
+ override_tpu: TpuSpec | None = None,
272
+ cpu_enforcement_policy: ResourceMode = ResourceMode.AUTO,
273
+ memory_enforcement_policy: ResourceMode = ResourceMode.AUTO,
274
+ persistent_env: dict[str, str] | None = None,
275
+ mounts: list[ServiceVolumeConfig] | None = None,
276
+ network_policy: NetworkPolicy | None = None,
277
+ phase_network_policies: Sequence[NetworkPolicy] | None = None,
278
+ extra_docker_compose: Sequence[Path | str] | None = None,
279
+ **_ignored: object,
280
+ ) -> None:
281
+ super().__init__(
282
+ environment_dir=environment_dir,
283
+ environment_name=environment_name,
284
+ session_id=session_id,
285
+ trial_paths=trial_paths,
286
+ task_env_config=task_env_config.model_copy(deep=True),
287
+ logger=logger,
288
+ override_cpus=override_cpus,
289
+ override_memory_mb=override_memory_mb,
290
+ override_storage_mb=override_storage_mb,
291
+ override_gpus=override_gpus,
292
+ override_tpu=override_tpu,
293
+ cpu_enforcement_policy=cpu_enforcement_policy,
294
+ memory_enforcement_policy=memory_enforcement_policy,
295
+ persistent_env=persistent_env,
296
+ mounts=mounts,
297
+ network_policy=network_policy,
298
+ phase_network_policies=phase_network_policies,
299
+ extra_docker_compose=extra_docker_compose,
300
+ )
301
+ # Build and create always pass explicit resources: E2B applies account defaults to
302
+ # omitted values, and an implicit default inside a shared alias would be a silent
303
+ # resource collision. IGNORE keeps harbor's semantics (task values not enforced).
304
+ effective_cpu = (
305
+ None if self._cpu_resource_mode is ResourceMode.IGNORE else self.task_env_config.cpus
306
+ )
307
+ effective_memory = (
308
+ None
309
+ if self._memory_resource_mode is ResourceMode.IGNORE
310
+ else self.task_env_config.memory_mb
311
+ )
312
+ self._template_resources = resolve_e2b_template_resources(
313
+ cpu_count=effective_cpu,
314
+ memory_mb=effective_memory,
315
+ )
316
+ self._build_source_kind: Literal["docker_image", "dockerfile"] = (
317
+ "docker_image" if self.task_env_config.docker_image else "dockerfile"
318
+ )
319
+ self._build_source_reference = self.task_env_config.docker_image or self.environment_id
320
+ self._template_name = qualify_harbor_e2b_template_name(
321
+ self._template_name,
322
+ environment_id=self.environment_id,
323
+ build_source_kind=self._build_source_kind,
324
+ build_source_reference=self._build_source_reference,
325
+ resources=self._template_resources,
326
+ )
327
+
328
+ @property
329
+ def template_name(self) -> str:
330
+ """Return the resource-qualified E2B template name."""
331
+ return self._template_name
332
+
333
+ @property
334
+ def template_resources(self) -> E2BTemplateResources:
335
+ """Return the exact numeric resources used for build and create."""
336
+ return self._template_resources
337
+
338
+ @property
339
+ @override
340
+ def _effective_cpus(self) -> int:
341
+ return self._template_resources.cpu_count
342
+
343
+ @property
344
+ @override
345
+ def _effective_memory_mb(self) -> int:
346
+ return self._template_resources.memory_mb
347
+
348
+ def _template_definition(self) -> TemplateClass:
349
+ if self.task_env_config.docker_image:
350
+ return Template().from_image(image=self.task_env_config.docker_image)
351
+ return Template(file_context_path=str(self.environment_dir)).from_dockerfile(
352
+ dockerfile_content_or_path=str(self._environment_definition_path)
353
+ )
354
+
355
+ @override
356
+ async def _create_template(self) -> BuildInfo:
357
+ """Submit once and poll the exact build without replaying submission.
358
+
359
+ A submission transport failure has an unknown outcome and propagates: retrying it is
360
+ exactly the duplicate-paid-build bug this override exists to prevent. Once E2B returns
361
+ ``BuildInfo``, only idempotent status GETs for that identity are retried.
362
+ """
363
+ build_info = await self._submit_template_build()
364
+ await _wait_for_template_build(build_info)
365
+ return build_info
366
+
367
+ async def _submit_template_build(self) -> BuildInfo:
368
+ """Submit the build exactly once; never wrapped in any retry."""
369
+ return await AsyncTemplate.build_in_background(
370
+ template=self._template_definition(),
371
+ name=self._template_name,
372
+ cpu_count=self._template_resources.cpu_count,
373
+ memory_mb=self._template_resources.memory_mb,
374
+ )
375
+
376
+ async def _ensure_template_built(self, *, force_build: bool) -> None:
377
+ """Build the alias once per process, letting concurrent loops share one paid build.
378
+
379
+ The caller holds this loop's per-alias lock, so within one loop this runs once at a
380
+ time. Across loops, the process-wide registry decides: the first claimant submits
381
+ (inside a process-wide build slot) and records the build identity BEFORE polling, so
382
+ any other loop finds the identity and polls THAT build to READY instead of paying for
383
+ a second one. A terminal build failure clears the entry so a later attempt can
384
+ rebuild; a submission whose outcome is unknown stays claimed as ambiguous and must be
385
+ reconciled with the control plane before anyone may resubmit.
386
+ """
387
+ alias = self._template_name
388
+ if force_build:
389
+ _clear_submitted_build(alias, None)
390
+ while True:
391
+ claim = _claim_build(alias)
392
+ if claim == "owner":
393
+ await self._acquire_slot_and_build(alias)
394
+ return
395
+ if isinstance(claim, _SubmittedBuild):
396
+ self.logger.debug(f"Awaiting template {alias} submitted by a concurrent job")
397
+ await _await_submitted_build(alias, claim)
398
+ return
399
+ if isinstance(claim, _AmbiguousSubmission):
400
+ await self._reconcile_ambiguous_submission(alias, claim)
401
+ return
402
+ # "pending": another caller's submission or reconciliation is in flight; wait for
403
+ # its identity to land (poll it) or for its failure to release the claim.
404
+ await asyncio.sleep(_REGISTRY_POLL_INTERVAL_S)
405
+
406
+ async def _acquire_slot_and_build(self, alias: str) -> None:
407
+ """Owner path: submit under a process-wide build slot and poll to READY."""
408
+ await _acquire_build_slot()
409
+ try:
410
+ self.logger.debug(f"Creating template {alias}")
411
+ try:
412
+ build_info = await self._submit_template_build()
413
+ except BaseException as error:
414
+ # E2B may have accepted the request before the failure, so a paid build may be
415
+ # running without us holding its id. Keep the alias CLAIMED as ambiguous: no
416
+ # waiter may submit a second paid build until a later attempt reconciles with
417
+ # the control plane. The failure itself propagates (never auto-resubmitted).
418
+ _record_submitted_build(
419
+ alias,
420
+ _AmbiguousSubmission(error=f"{type(error).__name__}: {error}"),
421
+ )
422
+ raise
423
+ _record_submitted_build(
424
+ alias,
425
+ _SubmittedBuild(
426
+ template_id=build_info.template_id,
427
+ build_id=build_info.build_id,
428
+ ),
429
+ )
430
+ try:
431
+ await _wait_for_template_build(build_info)
432
+ except BaseException:
433
+ _clear_submitted_build(alias, None) # terminal failure: allow a rebuild
434
+ raise
435
+ finally:
436
+ _PROCESS_BUILD_SLOTS.release()
437
+
438
+ async def _reconcile_ambiguous_submission(
439
+ self,
440
+ alias: str,
441
+ ambiguous: _AmbiguousSubmission,
442
+ ) -> None:
443
+ """Resolve an earlier submission of unknown outcome before allowing any resubmission.
444
+
445
+ `_claim_build` converted the ambiguous entry into a live claim, so this caller is the
446
+ single reconciler and holds the claim for the whole window (no other loop can resubmit
447
+ meanwhile). The control plane is the authority, but E2B alias visibility is eventually
448
+ consistent: an accepted build can be invisible for a few seconds, so a single False
449
+ must NOT be read as "rejected". Poll alias visibility over a bounded window; if the
450
+ alias appears at any check, the earlier submission went through, so release the claim
451
+ with nothing re-paid. Only if it stays absent across the ENTIRE window may this
452
+ claimant clear the marker and submit fresh. An ambiguous submission never yielded a
453
+ build id, so alias resolution is the completion evidence; there is no exact build to
454
+ poll.
455
+ """
456
+ self.logger.debug(
457
+ f"Reconciling an ambiguous earlier submission of template {alias} ({ambiguous.error})"
458
+ )
459
+ for check in range(_AMBIGUOUS_VISIBILITY_CHECKS):
460
+ try:
461
+ exists = await self._does_template_exist()
462
+ except BaseException:
463
+ _record_submitted_build(alias, ambiguous) # still unknown: restore the marker
464
+ raise
465
+ if exists:
466
+ _clear_submitted_build(alias, None)
467
+ return
468
+ if check + 1 < _AMBIGUOUS_VISIBILITY_CHECKS:
469
+ await asyncio.sleep(_AMBIGUOUS_VISIBILITY_DELAY_S)
470
+ # Absent across the whole eventual-consistency window: the original submission was
471
+ # truly rejected. Safe to submit a fresh build.
472
+ await self._acquire_slot_and_build(alias)
473
+
474
+ @override
475
+ async def _create_sandbox(self) -> None:
476
+ """Create this trial's sandbox, pacing, ledgering, and waiting out 429s.
477
+
478
+ Two independent retry budgets. Harbor's contract (two attempts, one short pause)
479
+ governs genuine create failures. A retryable status error, which for a create means
480
+ E2B's 429 for the account's concurrent-sandbox cap, instead waits on
481
+ `_CREATE_RATE_LIMIT_DELAYS_S`: those slots free only as other trials finish, so waiting
482
+ is the correct response and failing the trial is not. Both budgets are bounded, so a
483
+ permanently full account still fails.
484
+ """
485
+ metadata = {
486
+ "environment_name": self.environment_name,
487
+ "session_id": self.session_id,
488
+ }
489
+ self._sandbox = None
490
+ attempts = 0
491
+ rate_limit_retries = 0
492
+ while True:
493
+ await acquire_e2b_create_slot_async()
494
+ try:
495
+ sandbox = await AsyncSandbox.create(
496
+ template=self._template_name,
497
+ metadata=metadata,
498
+ envs=self._startup_env(),
499
+ timeout=86_400,
500
+ allow_internet_access=(
501
+ self.network_policy.network_mode != NetworkMode.NO_NETWORK
502
+ ),
503
+ network=self._sandbox_create_network_options(),
504
+ )
505
+ except Exception as error: # noqa: BLE001 - preserve Harbor's retry-all contract
506
+ if _is_retryable_status_error(error) and rate_limit_retries < len(
507
+ _CREATE_RATE_LIMIT_DELAYS_S
508
+ ):
509
+ delay = _CREATE_RATE_LIMIT_DELAYS_S[rate_limit_retries]
510
+ rate_limit_retries += 1
511
+ self.logger.warning(
512
+ "E2B rejected a sandbox create as retryable (%s); waiting %.0fs "
513
+ "(attempt %d of %d) for a concurrency slot",
514
+ error,
515
+ delay,
516
+ rate_limit_retries,
517
+ len(_CREATE_RATE_LIMIT_DELAYS_S),
518
+ )
519
+ await asyncio.sleep(delay)
520
+ continue
521
+ attempts += 1
522
+ if attempts >= _CREATE_ATTEMPTS:
523
+ raise
524
+ await asyncio.sleep(_CREATE_RETRY_DELAY_S)
525
+ continue
526
+ # Ledger the sandbox BEFORE anything else can fail: from here on E2B is holding a
527
+ # billable concurrency slot, and a crash between create and cleanup must still
528
+ # leave an exact id for `wmo e2b reap`.
529
+ self._record_created(sandbox)
530
+ # One cheap identity check: the sandbox must really come from the qualified alias.
531
+ # It lives INSIDE the create retry so one transient get_info failure retries the
532
+ # whole create instead of failing it; a genuine mismatch stays immediately fatal.
533
+ try:
534
+ info = await sandbox.get_info()
535
+ except BaseException as error:
536
+ await self._kill_quietly(sandbox)
537
+ attempts += 1
538
+ if isinstance(error, Exception) and attempts < _CREATE_ATTEMPTS:
539
+ await asyncio.sleep(_CREATE_RETRY_DELAY_S)
540
+ continue
541
+ raise
542
+ if info.name is not None and info.name != self._template_name:
543
+ await self._kill_quietly(sandbox)
544
+ raise RuntimeError("E2B sandbox template name mismatch")
545
+ self._sandbox = sandbox
546
+ return
547
+
548
+ def _record_created(self, sandbox: AsyncSandbox) -> None:
549
+ """Append this sandbox to the reap ledger (never fatal: the sandbox already exists)."""
550
+ default_ledger().record_created(
551
+ sandbox_id=sandbox.sandbox_id,
552
+ template_id=self._template_name,
553
+ trial_name=harbor_trial_name(self.session_id),
554
+ )
555
+
556
+ def _record_released(self, sandbox: AsyncSandbox) -> None:
557
+ """Mark a proven kill in the reap ledger so no reaper considers the sandbox."""
558
+ default_ledger().record_released(sandbox.sandbox_id)
559
+
560
+ async def _kill_quietly(self, sandbox: AsyncSandbox) -> None:
561
+ """Best-effort kill of a sandbox this environment is abandoning."""
562
+ try:
563
+ await sandbox.kill()
564
+ except Exception: # noqa: BLE001 - the create/identity error stays authoritative
565
+ self.logger.warning("failed to kill an abandoned E2B sandbox", exc_info=True)
566
+ return
567
+ self._record_released(sandbox)
568
+
569
+ @override
570
+ async def _stop_sandbox(self) -> None:
571
+ """Harbor's teardown, plus a ledger release once the kill is proved."""
572
+ sandbox = self._sandbox
573
+ await super()._stop_sandbox()
574
+ if sandbox is not None:
575
+ self._record_released(sandbox)
576
+
577
+ @override
578
+ async def start(self, force_build: bool) -> None:
579
+ """Harbor's exists-then-build start, made race-free and concurrency-bounded."""
580
+ async with _template_lock(self._template_name):
581
+ if force_build or not await self._does_template_exist():
582
+ await self._ensure_template_built(force_build=force_build)
583
+ await self._create_sandbox()
584
+ if not self._sandbox:
585
+ raise RuntimeError("Sandbox not found but was just created. This should never happen.")
586
+ await self.ensure_dirs(self._mount_targets(writable_only=True))
587
+ await self._upload_environment_dir_after_start()