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,476 @@
1
+ """Finding and killing orphaned E2B sandboxes, and measuring the account's free capacity.
2
+
3
+ An E2B account caps concurrent sandboxes (100 by default here, `$WMO_E2B_SANDBOX_CAP` when the
4
+ account differs). Harbor trial sandboxes hold their slot for their own timeout, so orphans of a
5
+ crashed run can starve every later run for hours. Reclaiming a slot means killing a sandbox,
6
+ which is destructive, so this module ranks its evidence:
7
+
8
+ - **Ledger (safe, default).** `wmo.harness.e2b_ledger` records every sandbox this machine
9
+ created together with its owning pid. An unreleased record whose owner process is gone is
10
+ provably an orphan of a wmo run that died, and killing it by exact id cannot touch anything
11
+ else.
12
+ - **Metadata (opt-in).** Harbor tags every trial's environment sandbox with
13
+ `session_id = "<trial>__env"` plus `environment_name`. Matching those on the ACCOUNT finds
14
+ orphans this machine never recorded (a run from before the ledger existed, another machine,
15
+ another checkout). The match is account-wide and can kill a colleague's live run, hence
16
+ opt-in behind an explicit age threshold. Even then, a sandbox whose ledger owner is still
17
+ alive is never a candidate: that is a running local trial.
18
+
19
+ The e2b SDK is the optional `e2b` extra, imported lazily so `wmo` stays usable without it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ from collections.abc import Callable, Sequence
26
+ from dataclasses import dataclass
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+ from typing import Literal, NamedTuple
30
+
31
+ from pydantic import BaseModel, ConfigDict, Field
32
+
33
+ from wmo.harness.e2b_ledger import (
34
+ LedgerFile,
35
+ SandboxCreated,
36
+ append_release,
37
+ harbor_trial_name,
38
+ pid_is_alive,
39
+ prune_released_files,
40
+ read_ledger_files,
41
+ )
42
+ from wmo.harness.e2b_sandbox import E2B_API_KEY_ENV
43
+
44
+ __all__ = [
45
+ "DEFAULT_E2B_SANDBOX_CAP",
46
+ "E2B_API_KEY_ENV",
47
+ "E2B_SANDBOX_CAP_ENV",
48
+ "MISSING_E2B_EXTRA",
49
+ "AliveSandbox",
50
+ "CapacityCheck",
51
+ "ReapCandidate",
52
+ "ReapOutcome",
53
+ "ReapPlan",
54
+ "SandboxKiller",
55
+ "SandboxLister",
56
+ "check_capacity",
57
+ "execute_reap",
58
+ "is_credential_error",
59
+ "kill_sandbox",
60
+ "list_alive_sandboxes",
61
+ "pid_is_alive",
62
+ "plan_reap",
63
+ "sandbox_cap",
64
+ ]
65
+
66
+ E2B_SANDBOX_CAP_ENV = "WMO_E2B_SANDBOX_CAP"
67
+ """Override for the account's concurrent-sandbox cap; accounts differ by plan."""
68
+
69
+ DEFAULT_E2B_SANDBOX_CAP = 100
70
+ """E2B's default concurrent-sandbox limit per account."""
71
+
72
+ MISSING_E2B_EXTRA = (
73
+ "the e2b SDK is not installed; run `uv sync --extra e2b` to manage E2B sandbox capacity"
74
+ )
75
+
76
+ _HARBOR_ENVIRONMENT_METADATA_KEY = "environment_name"
77
+ _HARBOR_SESSION_METADATA_KEY = "session_id"
78
+
79
+
80
+ def is_credential_error(error: Exception) -> bool:
81
+ """Whether an E2B call failed because the credential is missing or rejected.
82
+
83
+ Matched by exception NAME so callers need neither the optional SDK nor a fake that imports
84
+ it: e2b raises `AuthenticationException` both for an unset `$E2B_API_KEY` and for a 401.
85
+ A credential problem is a configuration error, not a transient one, so callers fail on it
86
+ instead of treating it like an unreachable API.
87
+ """
88
+ return type(error).__name__ == "AuthenticationException"
89
+
90
+
91
+ def sandbox_cap() -> int:
92
+ """The account's concurrent-sandbox cap.
93
+
94
+ Returns:
95
+ `$WMO_E2B_SANDBOX_CAP` when set, else E2B's default of 100.
96
+
97
+ Raises:
98
+ ValueError: If the environment variable is not a positive integer.
99
+ """
100
+ raw = os.environ.get(E2B_SANDBOX_CAP_ENV, "").strip()
101
+ if not raw:
102
+ return DEFAULT_E2B_SANDBOX_CAP
103
+ try:
104
+ value = int(raw)
105
+ except ValueError as error:
106
+ raise ValueError(
107
+ f"${E2B_SANDBOX_CAP_ENV} must be a positive integer (your account's concurrent "
108
+ f"sandbox limit), got {raw!r}"
109
+ ) from error
110
+ if value < 1:
111
+ raise ValueError(f"${E2B_SANDBOX_CAP_ENV} must be a positive integer, got {value}")
112
+ return value
113
+
114
+
115
+ class AliveSandbox(BaseModel):
116
+ """One sandbox E2B currently reports as running on this account."""
117
+
118
+ model_config = ConfigDict(frozen=True)
119
+
120
+ sandbox_id: str
121
+ template_id: str
122
+ started_at: datetime
123
+ metadata: dict[str, str] = Field(default_factory=dict)
124
+
125
+ def is_harbor_trial(self) -> bool:
126
+ """Whether the metadata identifies this as a harbor trial environment sandbox."""
127
+ session_id = self.metadata.get(_HARBOR_SESSION_METADATA_KEY, "")
128
+ return bool(
129
+ self.metadata.get(_HARBOR_ENVIRONMENT_METADATA_KEY)
130
+ and harbor_trial_name(session_id) is not None
131
+ )
132
+
133
+ def trial_name(self) -> str | None:
134
+ """The harbor trial name from the metadata, when the sandbox carries one."""
135
+ return harbor_trial_name(self.metadata.get(_HARBOR_SESSION_METADATA_KEY, ""))
136
+
137
+
138
+ SandboxLister = Callable[[], list[AliveSandbox]]
139
+ """Lists every running sandbox on the account."""
140
+
141
+ SandboxKiller = Callable[[str], bool]
142
+ """Kills one sandbox by id; False means E2B reported it already gone."""
143
+
144
+
145
+ class ReapCandidate(BaseModel):
146
+ """One sandbox a reap would kill, carrying the evidence for killing it."""
147
+
148
+ model_config = ConfigDict(frozen=True)
149
+
150
+ sandbox_id: str
151
+ template_id: str
152
+ age_seconds: float
153
+ source: Literal["ledger", "metadata"]
154
+ """Which evidence selected it: this machine's ledger, or an account-wide metadata match."""
155
+
156
+ trial_name: str | None = None
157
+ owner_pid: int | None = None
158
+ """The recorded owning process, or None when only the account knows about the sandbox."""
159
+
160
+ owner_alive: bool | None = None
161
+ """Whether the owner still runs; None when no owner is recorded."""
162
+
163
+ ledger_path: Path | None = None
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class ReapPlan:
168
+ """What a reap would do, computed without touching the account."""
169
+
170
+ candidates: tuple[ReapCandidate, ...]
171
+ """Sandboxes to kill, oldest first."""
172
+
173
+ vanished: tuple[tuple[Path, SandboxCreated], ...]
174
+ """Dead-owner ledger records whose sandbox is already gone: release, never kill."""
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class ReapOutcome:
179
+ """The result of executing a plan."""
180
+
181
+ killed: tuple[str, ...]
182
+ """Sandboxes this reap actually stopped; each one freed a slot."""
183
+
184
+ already_gone: tuple[str, ...]
185
+ """Candidates E2B reported as no longer existing (no slot freed, ledger released)."""
186
+
187
+ failed: tuple[tuple[str, str], ...]
188
+ """(sandbox id, error) for kills that failed; the sweep continues past each one."""
189
+
190
+ pruned_ledgers: tuple[Path, ...]
191
+
192
+ @property
193
+ def freed(self) -> int:
194
+ """How many concurrency slots the reap actually released."""
195
+ return len(self.killed)
196
+
197
+
198
+ def list_alive_sandboxes() -> list[AliveSandbox]:
199
+ """Page through every RUNNING sandbox on the account (lazy SDK import).
200
+
201
+ `Sandbox.list()` returns a paginator, not an iterable, so pages are pulled while
202
+ `has_next` holds. Paused sandboxes hold no concurrency slot and are excluded.
203
+
204
+ Raises:
205
+ ImportError: If the optional `e2b` extra is not installed.
206
+ """
207
+ try:
208
+ from e2b import Sandbox
209
+ from e2b.sandbox.sandbox_api import SandboxQuery, SandboxState
210
+ except ImportError as error: # pragma: no cover - exercised only without the extra
211
+ raise ImportError(MISSING_E2B_EXTRA) from error
212
+
213
+ paginator = Sandbox.list(query=SandboxQuery(state=[SandboxState.RUNNING]))
214
+ alive: list[AliveSandbox] = []
215
+ while paginator.has_next:
216
+ for info in paginator.next_items():
217
+ alive.append(
218
+ AliveSandbox(
219
+ sandbox_id=info.sandbox_id,
220
+ template_id=info.name or info.template_id,
221
+ started_at=info.started_at,
222
+ metadata=dict(info.metadata or {}),
223
+ )
224
+ )
225
+ return alive
226
+
227
+
228
+ def kill_sandbox(sandbox_id: str) -> bool:
229
+ """Kill one sandbox by exact id; False when E2B reports it already gone.
230
+
231
+ Raises:
232
+ ImportError: If the optional `e2b` extra is not installed.
233
+ """
234
+ try:
235
+ from e2b import Sandbox
236
+ except ImportError as error: # pragma: no cover - exercised only without the extra
237
+ raise ImportError(MISSING_E2B_EXTRA) from error
238
+ return bool(Sandbox.kill(sandbox_id))
239
+
240
+
241
+ def plan_reap(
242
+ *,
243
+ alive: Sequence[AliveSandbox],
244
+ ledger_files: Sequence[LedgerFile],
245
+ now: datetime,
246
+ dead_owners: bool = True,
247
+ stale_minutes: int | None = None,
248
+ pid_alive: Callable[[int], bool] = pid_is_alive,
249
+ ) -> ReapPlan:
250
+ """Select which running sandboxes to kill.
251
+
252
+ Args:
253
+ alive: Every sandbox E2B currently reports as running.
254
+ ledger_files: This machine's ledger files (see `wmo.harness.e2b_ledger`).
255
+ now: The reference instant ages are measured against.
256
+ dead_owners: Include unreleased ledger records whose owner process is gone.
257
+ stale_minutes: When set, ALSO include account-wide harbor trial sandboxes older than
258
+ this many minutes. Account-wide: it can select another machine's live run.
259
+ pid_alive: Liveness probe for an owning pid (injected in tests).
260
+
261
+ Returns:
262
+ The candidates (oldest first) plus the dead-owner records whose sandbox is already
263
+ gone, which only need a ledger release.
264
+ """
265
+ alive_by_id = {sandbox.sandbox_id: sandbox for sandbox in alive}
266
+ owners = _ledger_owners(ledger_files, pid_alive)
267
+
268
+ candidates: dict[str, ReapCandidate] = {}
269
+ vanished: list[tuple[Path, SandboxCreated]] = []
270
+ if dead_owners:
271
+ for sandbox_id, owner in owners.items():
272
+ if owner.alive:
273
+ continue
274
+ sandbox = alive_by_id.get(sandbox_id)
275
+ if sandbox is None:
276
+ vanished.append((owner.path, owner.record))
277
+ continue
278
+ candidates[sandbox_id] = ReapCandidate(
279
+ sandbox_id=sandbox_id,
280
+ template_id=sandbox.template_id or owner.record.template_id,
281
+ age_seconds=_age_seconds(sandbox.started_at, now),
282
+ source="ledger",
283
+ trial_name=owner.record.trial_name or sandbox.trial_name(),
284
+ owner_pid=owner.record.pid,
285
+ owner_alive=False,
286
+ ledger_path=owner.path,
287
+ )
288
+
289
+ if stale_minutes is not None:
290
+ threshold = stale_minutes * 60
291
+ for sandbox in alive:
292
+ if sandbox.sandbox_id in candidates or not sandbox.is_harbor_trial():
293
+ continue
294
+ owner = owners.get(sandbox.sandbox_id)
295
+ # A sandbox whose ledger owner still runs is a LIVE local trial, never stale.
296
+ if owner is not None and owner.alive:
297
+ continue
298
+ age = _age_seconds(sandbox.started_at, now)
299
+ if age <= threshold:
300
+ continue
301
+ candidates[sandbox.sandbox_id] = ReapCandidate(
302
+ sandbox_id=sandbox.sandbox_id,
303
+ template_id=sandbox.template_id,
304
+ age_seconds=age,
305
+ source="metadata",
306
+ trial_name=sandbox.trial_name(),
307
+ owner_pid=owner.record.pid if owner is not None else None,
308
+ owner_alive=False if owner is not None else None,
309
+ ledger_path=owner.path if owner is not None else None,
310
+ )
311
+
312
+ ordered = sorted(candidates.values(), key=lambda item: item.age_seconds, reverse=True)
313
+ return ReapPlan(candidates=tuple(ordered), vanished=tuple(vanished))
314
+
315
+
316
+ class _LedgerOwner(NamedTuple):
317
+ """The local record behind one still-held sandbox id, plus its owner's liveness."""
318
+
319
+ path: Path
320
+ record: SandboxCreated
321
+ alive: bool
322
+
323
+
324
+ def _ledger_owners(
325
+ ledger_files: Sequence[LedgerFile],
326
+ pid_alive: Callable[[int], bool],
327
+ ) -> dict[str, _LedgerOwner]:
328
+ """Index every still-held ledger record by sandbox id, probing each pid at most once."""
329
+ liveness: dict[int, bool] = {}
330
+ owners: dict[str, _LedgerOwner] = {}
331
+ for ledger in ledger_files:
332
+ if ledger.owner_pid not in liveness:
333
+ liveness[ledger.owner_pid] = pid_alive(ledger.owner_pid)
334
+ for record in ledger.held:
335
+ owners[record.sandbox_id] = _LedgerOwner(
336
+ ledger.path, record, liveness[ledger.owner_pid]
337
+ )
338
+ return owners
339
+
340
+
341
+ def _age_seconds(started_at: datetime, now: datetime) -> float:
342
+ """Sandbox age in seconds, tolerating a naive provider timestamp."""
343
+ started = started_at if started_at.tzinfo is not None else started_at.replace(tzinfo=UTC)
344
+ reference = now if now.tzinfo is not None else now.replace(tzinfo=UTC)
345
+ return max((reference - started).total_seconds(), 0.0)
346
+
347
+
348
+ def execute_reap(
349
+ plan: ReapPlan,
350
+ *,
351
+ killer: SandboxKiller = kill_sandbox,
352
+ ledger_directory: Path | None = None,
353
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
354
+ pid: int | None = None,
355
+ pid_alive: Callable[[int], bool] = pid_is_alive,
356
+ ) -> ReapOutcome:
357
+ """Kill every candidate in `plan`, then reconcile and prune the ledger.
358
+
359
+ Each id is killed independently: one failure is recorded and the sweep continues, because
360
+ one unkillable sandbox must not strand every other slot. A kill that succeeds (or that
361
+ E2B answers "already gone") appends a release record to the owning ledger file, and a ledger
362
+ file of a DEAD owner with nothing left held is deleted.
363
+ """
364
+ reaper_pid = pid if pid is not None else os.getpid()
365
+ killed: list[str] = []
366
+ already_gone: list[str] = []
367
+ failed: list[tuple[str, str]] = []
368
+ for candidate in plan.candidates:
369
+ try:
370
+ stopped = killer(candidate.sandbox_id)
371
+ except Exception as error: # noqa: BLE001 - one bad id must not abort the sweep
372
+ failed.append((candidate.sandbox_id, f"{type(error).__name__}: {error}"))
373
+ continue
374
+ if stopped:
375
+ killed.append(candidate.sandbox_id)
376
+ else:
377
+ already_gone.append(candidate.sandbox_id)
378
+ _release(candidate.ledger_path, candidate.sandbox_id, reaper_pid, now())
379
+ for path, record in plan.vanished:
380
+ _release(path, record.sandbox_id, reaper_pid, now())
381
+ return ReapOutcome(
382
+ killed=tuple(killed),
383
+ already_gone=tuple(already_gone),
384
+ failed=tuple(failed),
385
+ pruned_ledgers=prune_released_files(ledger_directory, owner_alive=pid_alive),
386
+ )
387
+
388
+
389
+ def _release(path: Path | None, sandbox_id: str, pid: int, released_at: datetime) -> None:
390
+ """Best-effort ledger release; a missing owner file or write error is not fatal."""
391
+ if path is None:
392
+ return
393
+ try:
394
+ append_release(path, sandbox_id=sandbox_id, pid=pid, released_at=released_at)
395
+ except OSError:
396
+ pass # the sandbox is dead either way; the ledger just keeps a stale record
397
+
398
+
399
+ @dataclass(frozen=True)
400
+ class CapacityCheck:
401
+ """The account's concurrent-sandbox capacity around one preflight."""
402
+
403
+ cap: int
404
+ alive_before: int
405
+ """Running sandboxes counted before any reap."""
406
+
407
+ alive: int
408
+ """Running sandboxes after reaping this machine's provable orphans."""
409
+
410
+ required: int
411
+ outcome: ReapOutcome | None = None
412
+ """The reap that ran because the first count was short, or None when none was needed."""
413
+
414
+ @property
415
+ def free(self) -> int:
416
+ """Slots available for new sandboxes."""
417
+ return max(self.cap - self.alive, 0)
418
+
419
+ @property
420
+ def ok(self) -> bool:
421
+ """Whether the run can claim the concurrency it asks for."""
422
+ return self.free >= self.required
423
+
424
+ @property
425
+ def reaped(self) -> int:
426
+ """How many orphan slots the preflight reclaimed."""
427
+ return 0 if self.outcome is None else self.outcome.freed
428
+
429
+
430
+ def check_capacity(
431
+ *,
432
+ required: int,
433
+ cap: int | None = None,
434
+ lister: SandboxLister = list_alive_sandboxes,
435
+ killer: SandboxKiller = kill_sandbox,
436
+ ledger_directory: Path | None = None,
437
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
438
+ pid_alive: Callable[[int], bool] = pid_is_alive,
439
+ ) -> CapacityCheck:
440
+ """Count running sandboxes and, when short of `required`, reap provable orphans.
441
+
442
+ Only the dead-owner class is reaped: exact ids from this machine's ledger whose owning
443
+ process is gone. The account-wide metadata sweep stays a deliberate operator action
444
+ (`wmo e2b reap --stale-minutes N`), never something a run does on its own.
445
+
446
+ Returns:
447
+ The capacity picture after any reap, including whether `required` slots are free.
448
+ """
449
+ limit = cap if cap is not None else sandbox_cap()
450
+ alive = lister()
451
+ if limit - len(alive) >= required:
452
+ return CapacityCheck(
453
+ cap=limit, alive_before=len(alive), alive=len(alive), required=required
454
+ )
455
+ plan = plan_reap(
456
+ alive=alive,
457
+ ledger_files=read_ledger_files(ledger_directory),
458
+ now=now(),
459
+ dead_owners=True,
460
+ stale_minutes=None,
461
+ pid_alive=pid_alive,
462
+ )
463
+ outcome = execute_reap(
464
+ plan,
465
+ killer=killer,
466
+ ledger_directory=ledger_directory,
467
+ now=now,
468
+ pid_alive=pid_alive,
469
+ )
470
+ return CapacityCheck(
471
+ cap=limit,
472
+ alive_before=len(alive),
473
+ alive=max(len(alive) - outcome.freed, 0),
474
+ required=required,
475
+ outcome=outcome,
476
+ )