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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- 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()
|