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
wmo/harness/pi_e2b.py
ADDED
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
"""The pi agent inside E2B sandboxes: a stdio frame channel + the runtime that drives it.
|
|
2
|
+
|
|
3
|
+
For pi-node harnesses the agent *process* itself must run for real (its multi-file TypeScript
|
|
4
|
+
source — context forking, dropping, summarizing — is the thing under search), while the worker LLM
|
|
5
|
+
and the tool routing stay host-side. The ENVIRONMENT is whatever `AgentEnvironment` the eval
|
|
6
|
+
binds; in `wmo optimize harness` / `wmo eval` that is the world-model simulation. The sandbox is
|
|
7
|
+
purely the compute substrate for the harness process, and its filesystem is never an environment.
|
|
8
|
+
|
|
9
|
+
`E2BStdioChannel` carries the existing RunnerLink frame protocol over an E2B background command's
|
|
10
|
+
stdin/stdout — one base64(JSON) frame per line, because the sandbox command channel is a text
|
|
11
|
+
stream — and `E2BPiRuntime` composes it: acquire a sandbox from the runtime's own pool (create +
|
|
12
|
+
bootstrap on demand: upload `pi_entry/runner_stdio.ts`, install node 22 + the vendored pi's npm
|
|
13
|
+
deps unless a prebaked template supplies them), start the runner, await its `hello`, then delegate
|
|
14
|
+
the whole episode to `wmo.harness.runner_link.RunnerLink` — zero duplication of episode logic, and
|
|
15
|
+
the creds-stay-host-side invariant RunnerLink was built for holds (only frames enter the sandbox).
|
|
16
|
+
|
|
17
|
+
Sandboxes are pooled per runtime instance and reused across sequential episodes (bootstrap is
|
|
18
|
+
paid once per sandbox, not per rollout); concurrent episodes each acquire their own sandbox, so
|
|
19
|
+
rollouts parallelize naturally — no process-wide `_ACTIVE_CHANNEL` singleton, no max_concurrent:1
|
|
20
|
+
limit. The runner's stderr never carries frames; it is collected in a bounded deque and surfaced
|
|
21
|
+
in every transport error so a crashed node process diagnoses itself.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import contextlib
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import queue
|
|
32
|
+
import re
|
|
33
|
+
import shlex
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
from collections import deque
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
40
|
+
from typing import Literal, cast, overload
|
|
41
|
+
|
|
42
|
+
from wmo.core.types import JsonObject
|
|
43
|
+
from wmo.harness.e2b_sandbox import (
|
|
44
|
+
DEFAULT_SANDBOX_TIMEOUT_S,
|
|
45
|
+
E2B_TEMPLATE_ENV,
|
|
46
|
+
CommandHandle,
|
|
47
|
+
SandboxCleanupError,
|
|
48
|
+
SandboxFactory,
|
|
49
|
+
SandboxHandle,
|
|
50
|
+
SandboxUsage,
|
|
51
|
+
create_sandbox,
|
|
52
|
+
default_sandbox_factory,
|
|
53
|
+
kill_sandbox,
|
|
54
|
+
resolve_e2b_template,
|
|
55
|
+
)
|
|
56
|
+
from wmo.harness.environment import AgentEnvironment
|
|
57
|
+
from wmo.harness.runner_link import RunnerLink, WorkerFn
|
|
58
|
+
from wmo.harness.runtime import (
|
|
59
|
+
DEFAULT_EVAL_EPISODE_TIMEOUT_S,
|
|
60
|
+
DEFAULT_MAX_OUTPUT_TOKENS,
|
|
61
|
+
DEFAULT_MAX_TURNS,
|
|
62
|
+
RunResult,
|
|
63
|
+
RuntimeCancelled,
|
|
64
|
+
StopReason,
|
|
65
|
+
validate_episode_timeout_s,
|
|
66
|
+
)
|
|
67
|
+
from wmo.harness.skills import SkillLibrary
|
|
68
|
+
from wmo.harness.tools import ToolSpec
|
|
69
|
+
from wmo.providers.base import ToolCallingProvider
|
|
70
|
+
|
|
71
|
+
RUNNER_WORKDIR = "/home/user/pi-run"
|
|
72
|
+
|
|
73
|
+
# The npm packages the vendored pi source imports (verified against
|
|
74
|
+
# vendor/pi-agent/package.json [dependencies] and the import statements under vendor/pi-agent/src;
|
|
75
|
+
# same list the 65520da E2B backend installed, now version-pinned). Everything else is node:*.
|
|
76
|
+
PI_NPM_PACKAGES = (
|
|
77
|
+
"@earendil-works/pi-ai@0.80.3",
|
|
78
|
+
"ignore@7.0.5",
|
|
79
|
+
"typebox@1.1.38",
|
|
80
|
+
"yaml@2.9.0",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# The base image ships node 20.9; pi needs >= 22.6 for --experimental-strip-types (65520da
|
|
84
|
+
# precedent for the `n`-based upgrade). Installs are slow, hence the generous cap.
|
|
85
|
+
NODE_INSTALL_CMD = "npm install -g n && n 22"
|
|
86
|
+
INSTALL_TIMEOUT_S = 600.0
|
|
87
|
+
START_CMD = f"cd {RUNNER_WORKDIR} && node --experimental-strip-types runner_stdio.ts"
|
|
88
|
+
# The live-session runner (interactive multi-turn) vs. the episode runner (one-shot eval).
|
|
89
|
+
LIVE_START_CMD = f"cd {RUNNER_WORKDIR} && node --experimental-strip-types runner_live.ts"
|
|
90
|
+
_LIVE_RUNNER_FILES = ("runner_live.ts",)
|
|
91
|
+
# Default working directory a live session's real tools operate in (the agent's "cwd").
|
|
92
|
+
LIVE_WORKSPACE = "/home/user/workspace"
|
|
93
|
+
HELLO_TIMEOUT_S = 60.0
|
|
94
|
+
|
|
95
|
+
# ESM so node treats the runner's .ts files as modules regardless of syntax detection.
|
|
96
|
+
_PACKAGE_JSON = '{"name": "pi-run", "private": true, "type": "module"}\n'
|
|
97
|
+
|
|
98
|
+
_PI_ENTRY_DIR = os.path.join(os.path.dirname(__file__), "pi_entry")
|
|
99
|
+
# runner_stdio.ts is the entrypoint; runner_frames.ts rides along so the two runner transports
|
|
100
|
+
# stay deployed together (spec §4) even though the stdio runner is self-contained.
|
|
101
|
+
# runner_termination.ts is a hard dependency of runner_stdio.ts (the classify + nudge policy).
|
|
102
|
+
_RUNNER_FILES = ("runner_stdio.ts", "runner_frames.ts", "runner_termination.ts")
|
|
103
|
+
|
|
104
|
+
_STDERR_LINES = 50 # bounded diagnostics buffer: enough for a stack trace, never unbounded
|
|
105
|
+
_IDLE_RECONNECT_DELAYS_S = (0.0, 0.25, 1.0)
|
|
106
|
+
|
|
107
|
+
# Durable live sessions mirror every runner -> host semantic frame into a sequenced filesystem
|
|
108
|
+
# outbox. Stdout remains the low-latency path; these deliberately short unary RPC bounds keep a
|
|
109
|
+
# 500ms LiveSession pump (and cancellation) from inheriting the E2B SDK's much longer default
|
|
110
|
+
# request timeout when it polls the outbox.
|
|
111
|
+
_DURABLE_FILE_REQUEST_TIMEOUT_S = 0.25
|
|
112
|
+
_DURABLE_FRAME_REQUEST_TIMEOUT_S = 5.0
|
|
113
|
+
_DURABLE_POLL_INTERVAL_S = 0.25
|
|
114
|
+
_DURABLE_STDOUT_FAST_WAIT_S = 0.025
|
|
115
|
+
_DURABLE_STREAM_DEATH_GRACE_S = 0.5
|
|
116
|
+
_DURABLE_FRAME_READ_GRACE_S = 5.0
|
|
117
|
+
_DURABLE_PID_PROBE_INTERVAL_S = 5.0
|
|
118
|
+
_DURABLE_SEND_REQUEST_TIMEOUT_S = 2.0
|
|
119
|
+
_DURABLE_SEND_ACK_TIMEOUT_S = 5.0
|
|
120
|
+
_DURABLE_CLOSE_REQUEST_TIMEOUT_S = 0.25
|
|
121
|
+
_DURABLE_STDOUT_SILENCE_GRACE_S = 45.0
|
|
122
|
+
|
|
123
|
+
# A runner-originated heartbeat keeps the background command stream active while the host is
|
|
124
|
+
# synchronously waiting on a slow provider call. Pooled evaluation channels also use the same
|
|
125
|
+
# heartbeat to renew the sandbox lease; live-session callers own their own idle/suspend lifecycle
|
|
126
|
+
# and therefore leave renewal disabled.
|
|
127
|
+
TRANSPORT_KEEPALIVE_TYPE = "transport_keepalive"
|
|
128
|
+
_SANDBOX_TIMEOUT_REFRESH_S = 300.0
|
|
129
|
+
# Renewal must not turn mutated agent code into an unbounded cost leak. The default hard lifetime
|
|
130
|
+
# is one hour; a longer episode budget adds one default lease for in-flight work and cleanup.
|
|
131
|
+
MAX_EVAL_EPISODE_LIFETIME_S = 3_600.0
|
|
132
|
+
_MAX_RETIRE_WORKERS = 16
|
|
133
|
+
|
|
134
|
+
_HTTPCORE_REMOTE_STREAM_RESET = re.compile(
|
|
135
|
+
r"<StreamReset stream_id:\d+, error_code:\d+, remote_reset:True>"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _episode_lease_lifetime_s(episode_timeout_s: float) -> float:
|
|
140
|
+
"""Keep the legacy default, or cover one synchronous call plus cleanup after the watchdog."""
|
|
141
|
+
if episode_timeout_s <= DEFAULT_EVAL_EPISODE_TIMEOUT_S:
|
|
142
|
+
return DEFAULT_SANDBOX_TIMEOUT_S
|
|
143
|
+
return episode_timeout_s + DEFAULT_SANDBOX_TIMEOUT_S
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Eof:
|
|
147
|
+
"""Reader-thread sentinel: the runner process's output stream ended."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_EOF = _Eof()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _E2BChannelSendError(RuntimeError):
|
|
154
|
+
"""A durable host frame could not be acknowledged by the E2B runner."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class E2BStdioChannel:
|
|
158
|
+
"""A `runner_link.Channel` over an E2B background command's stdin/stdout.
|
|
159
|
+
|
|
160
|
+
send: base64(JSON) + newline into the process's stdin (`commands.send_stdin`). recv: a blocking
|
|
161
|
+
queue fed by a daemon reader thread that iterates the command handle's stream events,
|
|
162
|
+
reassembles partial stdout lines, and decodes each complete line as one frame. stderr is never
|
|
163
|
+
parsed as frames — it goes to a bounded deque surfaced in error messages. Once the process
|
|
164
|
+
exits, recv raises a `RuntimeError` carrying that stderr tail (unless `close()` initiated the
|
|
165
|
+
shutdown, in which case recv reports a clean end-of-channel `None`).
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
sandbox: SandboxHandle,
|
|
171
|
+
handle: CommandHandle,
|
|
172
|
+
*,
|
|
173
|
+
stderr_lines: int = _STDERR_LINES,
|
|
174
|
+
sandbox_timeout_s: int | None = None,
|
|
175
|
+
timeout_refresh_interval_s: float = _SANDBOX_TIMEOUT_REFRESH_S,
|
|
176
|
+
max_episode_lifetime_s: float = MAX_EVAL_EPISODE_LIFETIME_S,
|
|
177
|
+
reconnect_while_idle: bool = False,
|
|
178
|
+
) -> None:
|
|
179
|
+
self._sandbox = sandbox
|
|
180
|
+
self._handle = handle
|
|
181
|
+
self._pid = handle.pid
|
|
182
|
+
self._frames: queue.Queue[JsonObject | _Eof] = queue.Queue()
|
|
183
|
+
self._stderr: deque[str] = deque(maxlen=stderr_lines)
|
|
184
|
+
self._sandbox_timeout_s = sandbox_timeout_s
|
|
185
|
+
self._timeout_refresh_interval_s = timeout_refresh_interval_s
|
|
186
|
+
self._max_episode_lifetime_s = max_episode_lifetime_s
|
|
187
|
+
self._next_timeout_refresh_at = 0.0
|
|
188
|
+
self._episode_renewal_deadline_at: float | None = None
|
|
189
|
+
self._reconnect_while_idle = reconnect_while_idle
|
|
190
|
+
# Guards the definitely-idle proof and the same-PID reconnect. A user message clears the
|
|
191
|
+
# proof while holding this lock *before* stdin delivery, so the reader cannot reconnect
|
|
192
|
+
# across a turn-start race where a semantic runner frame might be lost.
|
|
193
|
+
self._transport_lock = threading.Lock()
|
|
194
|
+
self._session_idle = False
|
|
195
|
+
self._closed = False
|
|
196
|
+
self._reader = threading.Thread(
|
|
197
|
+
target=self._read_events, name="e2b-stdio-reader", daemon=True
|
|
198
|
+
)
|
|
199
|
+
self._reader.start()
|
|
200
|
+
|
|
201
|
+
def send(self, frame: JsonObject) -> None:
|
|
202
|
+
kind = frame.get("type")
|
|
203
|
+
if kind == "episode_start" and self._sandbox_timeout_s is not None:
|
|
204
|
+
now = time.monotonic()
|
|
205
|
+
self._episode_renewal_deadline_at = now + self._max_episode_lifetime_s
|
|
206
|
+
# The pool reset the lease immediately before this episode. Wait until the normal
|
|
207
|
+
# refresh cadence instead of bursting one set_timeout call per sandbox at 30 seconds.
|
|
208
|
+
self._next_timeout_refresh_at = now + self._timeout_refresh_interval_s
|
|
209
|
+
line = base64.b64encode(json.dumps(frame).encode("utf-8")).decode("ascii") + "\n"
|
|
210
|
+
with self._transport_lock:
|
|
211
|
+
if kind in {"session_start", "user_message"}:
|
|
212
|
+
self._session_idle = False
|
|
213
|
+
try:
|
|
214
|
+
self._sandbox.commands.send_stdin(self._pid, line)
|
|
215
|
+
except OSError as exc:
|
|
216
|
+
raise _E2BChannelSendError("failed to send a frame to the E2B runner") from exc
|
|
217
|
+
except Exception as exc: # noqa: BLE001 - classify one optional-SDK transport shape
|
|
218
|
+
if _is_httpcore_remote_stream_reset(exc):
|
|
219
|
+
raise _E2BChannelSendError(
|
|
220
|
+
"failed to send a frame to the E2B runner after an HTTP/2 stream reset"
|
|
221
|
+
) from exc
|
|
222
|
+
raise
|
|
223
|
+
|
|
224
|
+
def recv(self, timeout: float | None = None) -> JsonObject | None:
|
|
225
|
+
"""The next frame from the runner; blocks (up to `timeout` seconds when given).
|
|
226
|
+
|
|
227
|
+
Timing out raises `TimeoutError`; a dead runner process raises `RuntimeError`; both
|
|
228
|
+
messages include the recent stderr. RunnerLink uses bounded receives for evaluation wall
|
|
229
|
+
budgets and cooperative cancellation; the hello handshake uses the same contract.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
item = self._frames.get(timeout=timeout)
|
|
233
|
+
except queue.Empty:
|
|
234
|
+
raise TimeoutError(
|
|
235
|
+
f"no frame from the pi runner within {timeout}s{self._stderr_suffix()}"
|
|
236
|
+
) from None
|
|
237
|
+
if isinstance(item, _Eof):
|
|
238
|
+
self._frames.put(item) # keep EOF sticky so every later recv sees it too
|
|
239
|
+
if self._closed:
|
|
240
|
+
return None # we asked it to shut down; a clean end-of-channel
|
|
241
|
+
raise RuntimeError(f"pi runner process exited mid-episode{self._stderr_suffix()}")
|
|
242
|
+
return item
|
|
243
|
+
|
|
244
|
+
def close(self) -> None:
|
|
245
|
+
"""Ask the runner to exit (best-effort); marks the stream end as clean for recv."""
|
|
246
|
+
with self._transport_lock:
|
|
247
|
+
if self._closed:
|
|
248
|
+
return
|
|
249
|
+
self._closed = True
|
|
250
|
+
self._session_idle = False
|
|
251
|
+
self._episode_renewal_deadline_at = None
|
|
252
|
+
try:
|
|
253
|
+
self.send({"type": "shutdown"})
|
|
254
|
+
except Exception: # noqa: BLE001 - the process/sandbox may already be gone; close is best-effort
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def stderr_tail(self) -> str:
|
|
258
|
+
"""The recent runner stderr (diagnostics; never part of the frame stream)."""
|
|
259
|
+
return "\n".join(self._stderr)
|
|
260
|
+
|
|
261
|
+
def _stderr_suffix(self) -> str:
|
|
262
|
+
tail = self.stderr_tail()
|
|
263
|
+
return f"; recent runner stderr:\n{tail}" if tail else ""
|
|
264
|
+
|
|
265
|
+
def _read_events(self) -> None:
|
|
266
|
+
pending = ""
|
|
267
|
+
handle = self._handle
|
|
268
|
+
while True:
|
|
269
|
+
try:
|
|
270
|
+
for stdout, stderr, _pty in handle:
|
|
271
|
+
if stderr:
|
|
272
|
+
for line in stderr.splitlines():
|
|
273
|
+
if line.strip():
|
|
274
|
+
self._stderr.append(line)
|
|
275
|
+
if not stdout:
|
|
276
|
+
continue
|
|
277
|
+
pending += stdout
|
|
278
|
+
while "\n" in pending:
|
|
279
|
+
line, pending = pending.split("\n", 1)
|
|
280
|
+
self._decode_line(line)
|
|
281
|
+
except Exception as exc: # noqa: BLE001 - classified by definitely-idle reconnect
|
|
282
|
+
reconnected = self._reconnect_idle_stream()
|
|
283
|
+
if reconnected is not None:
|
|
284
|
+
# A partially delivered line is not replay-safe. The runner writes one frame
|
|
285
|
+
# per line; dropping the fragment lets the next complete frame resynchronize.
|
|
286
|
+
pending = ""
|
|
287
|
+
handle = reconnected
|
|
288
|
+
continue
|
|
289
|
+
self._stderr.append(f"[channel] output stream failed: {exc}")
|
|
290
|
+
else:
|
|
291
|
+
# E2B's stream generator may also end without an exception when the HTTP stream
|
|
292
|
+
# disappears before a process-end event. Same-PID reconnect remains safe only at
|
|
293
|
+
# the exact idle boundary; a real process exit simply makes connect fail.
|
|
294
|
+
reconnected = self._reconnect_idle_stream()
|
|
295
|
+
if reconnected is not None:
|
|
296
|
+
pending = ""
|
|
297
|
+
handle = reconnected
|
|
298
|
+
continue
|
|
299
|
+
self._frames.put(_EOF)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
def _reconnect_idle_stream(self) -> CommandHandle | None:
|
|
303
|
+
"""Reattach to the same live runner only while it is provably between turns.
|
|
304
|
+
|
|
305
|
+
E2B command streams can suffer a transient HTTP/2 disconnect while their process and
|
|
306
|
+
sandbox remain healthy. Reconnecting mid-turn is not safe because an LLM/tool/state frame
|
|
307
|
+
may have been lost. Once ``state:idle`` was decoded, however, the runner emits only filtered
|
|
308
|
+
transport heartbeats until the next host ``user_message``; same-PID reconnect is lossless.
|
|
309
|
+
"""
|
|
310
|
+
if not self._reconnect_while_idle:
|
|
311
|
+
return None
|
|
312
|
+
for delay in _IDLE_RECONNECT_DELAYS_S:
|
|
313
|
+
if delay:
|
|
314
|
+
time.sleep(delay)
|
|
315
|
+
# Hold the proof across connect. `send(user_message)` takes this same lock and clears
|
|
316
|
+
# idle before stdin delivery, so it either happens entirely before or after reattach.
|
|
317
|
+
with self._transport_lock:
|
|
318
|
+
if self._closed or not self._session_idle:
|
|
319
|
+
return None
|
|
320
|
+
try:
|
|
321
|
+
handle = self._sandbox.commands.connect(self._pid, timeout=0)
|
|
322
|
+
except Exception: # noqa: BLE001 - the next bounded attempt uses a fresh stream
|
|
323
|
+
continue
|
|
324
|
+
self._handle = handle
|
|
325
|
+
return handle
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
def _decode_line(self, line: str) -> None:
|
|
329
|
+
text = line.strip()
|
|
330
|
+
if not text:
|
|
331
|
+
return
|
|
332
|
+
try:
|
|
333
|
+
frame = json.loads(base64.b64decode(text, validate=True))
|
|
334
|
+
except ValueError: # binascii.Error and JSONDecodeError are both ValueErrors
|
|
335
|
+
self._stderr.append(f"[stdout] {text}") # not a frame; keep it as a diagnostic
|
|
336
|
+
return
|
|
337
|
+
if isinstance(frame, dict):
|
|
338
|
+
if frame.get("type") == TRANSPORT_KEEPALIVE_TYPE:
|
|
339
|
+
self._refresh_sandbox_timeout()
|
|
340
|
+
return
|
|
341
|
+
if frame.get("type") == "state":
|
|
342
|
+
status = frame.get("status")
|
|
343
|
+
with self._transport_lock:
|
|
344
|
+
# Only an exact idle acknowledgement is a replay-safety proof. Any new or
|
|
345
|
+
# malformed state value conservatively disables transparent reconnect.
|
|
346
|
+
self._session_idle = status == "idle"
|
|
347
|
+
if frame.get("type") in {"done", "episode_error"}:
|
|
348
|
+
self._episode_renewal_deadline_at = None
|
|
349
|
+
self._frames.put(cast("JsonObject", frame))
|
|
350
|
+
else:
|
|
351
|
+
self._stderr.append(f"[stdout] {text}")
|
|
352
|
+
|
|
353
|
+
def _refresh_sandbox_timeout(self) -> None:
|
|
354
|
+
"""Renew a pooled eval sandbox lease without surfacing the heartbeat as a protocol frame.
|
|
355
|
+
|
|
356
|
+
A provider call can keep one episode active longer than E2B's 900-second sandbox timeout.
|
|
357
|
+
The runner remains responsive during that host-side wait and emits transport heartbeats;
|
|
358
|
+
refreshing here prevents E2B from killing an otherwise healthy in-flight episode. A
|
|
359
|
+
transient refresh failure stays diagnostic-only and the next heartbeat retries it.
|
|
360
|
+
"""
|
|
361
|
+
timeout = self._sandbox_timeout_s
|
|
362
|
+
deadline = self._episode_renewal_deadline_at
|
|
363
|
+
if timeout is None or deadline is None:
|
|
364
|
+
return
|
|
365
|
+
now = time.monotonic()
|
|
366
|
+
if now < self._next_timeout_refresh_at or now >= deadline:
|
|
367
|
+
return
|
|
368
|
+
# Shorten the final lease so the sandbox still dies at the absolute episode deadline.
|
|
369
|
+
refreshed_timeout = min(timeout, max(1, math.ceil(deadline - now)))
|
|
370
|
+
try:
|
|
371
|
+
self._sandbox.set_timeout(refreshed_timeout)
|
|
372
|
+
except Exception as exc: # noqa: BLE001 - retry on the next heartbeat; I/O remains authoritative
|
|
373
|
+
self._stderr.append(f"[channel] sandbox timeout refresh failed: {exc}")
|
|
374
|
+
return
|
|
375
|
+
self._next_timeout_refresh_at = now + self._timeout_refresh_interval_s
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class E2BDurableChannel:
|
|
379
|
+
"""A lossless live-runner channel backed by a sequenced E2B filesystem outbox.
|
|
380
|
+
|
|
381
|
+
Host -> runner traffic uses the same unary ``commands.send_stdin`` operation as
|
|
382
|
+
:class:`E2BStdioChannel`, wrapped in a monotonically sequenced envelope. The runner dispatches
|
|
383
|
+
each inbound sequence once and emits a durable acknowledgement, so an RPC that delivers bytes
|
|
384
|
+
and then times out can safely reuse the same sequence instead of replaying the whole agent
|
|
385
|
+
turn. In the other direction, the durable runner publishes each semantic frame as
|
|
386
|
+
``{transport_seq, frame}`` to an exact per-sequence file, advances ``head``, and only then
|
|
387
|
+
writes the same envelope to stdout. The stdout stream is therefore a fast notification path,
|
|
388
|
+
not the source of truth: one expected-sequence cursor deduplicates both sources, fills gaps
|
|
389
|
+
from exact files, and keeps polling ``head`` even when stdout is merely silent.
|
|
390
|
+
|
|
391
|
+
A command-stream EOF or HTTP failure is not a runner failure. The channel continues over unary
|
|
392
|
+
filesystem reads and only classifies process death after a grace period and an explicit
|
|
393
|
+
``commands.list`` result that omits the runner PID. This is what lets an ordinary
|
|
394
|
+
``LiveSession`` survive a dropped E2B output stream without changing its frame protocol.
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
def __init__(
|
|
398
|
+
self,
|
|
399
|
+
sandbox: SandboxHandle,
|
|
400
|
+
handle: CommandHandle,
|
|
401
|
+
*,
|
|
402
|
+
outbox_root: str,
|
|
403
|
+
stderr_path: str,
|
|
404
|
+
stderr_lines: int = _STDERR_LINES,
|
|
405
|
+
file_request_timeout_s: float = _DURABLE_FILE_REQUEST_TIMEOUT_S,
|
|
406
|
+
frame_request_timeout_s: float = _DURABLE_FRAME_REQUEST_TIMEOUT_S,
|
|
407
|
+
poll_interval_s: float = _DURABLE_POLL_INTERVAL_S,
|
|
408
|
+
stdout_fast_wait_s: float = _DURABLE_STDOUT_FAST_WAIT_S,
|
|
409
|
+
stream_death_grace_s: float = _DURABLE_STREAM_DEATH_GRACE_S,
|
|
410
|
+
frame_read_grace_s: float = _DURABLE_FRAME_READ_GRACE_S,
|
|
411
|
+
pid_probe_interval_s: float = _DURABLE_PID_PROBE_INTERVAL_S,
|
|
412
|
+
send_request_timeout_s: float = _DURABLE_SEND_REQUEST_TIMEOUT_S,
|
|
413
|
+
send_ack_timeout_s: float = _DURABLE_SEND_ACK_TIMEOUT_S,
|
|
414
|
+
close_request_timeout_s: float = _DURABLE_CLOSE_REQUEST_TIMEOUT_S,
|
|
415
|
+
stdout_silence_grace_s: float = _DURABLE_STDOUT_SILENCE_GRACE_S,
|
|
416
|
+
) -> None:
|
|
417
|
+
self._sandbox = sandbox
|
|
418
|
+
self._handle = handle
|
|
419
|
+
self._pid = handle.pid
|
|
420
|
+
self._outbox_root = outbox_root.rstrip("/")
|
|
421
|
+
self._stderr_path = stderr_path
|
|
422
|
+
self._stderr_lines = stderr_lines
|
|
423
|
+
self._stderr: deque[str] = deque(maxlen=stderr_lines)
|
|
424
|
+
self._durable_stderr = ""
|
|
425
|
+
self._frames: queue.Queue[JsonObject | _Eof] = queue.Queue()
|
|
426
|
+
self._state_lock = threading.Lock()
|
|
427
|
+
self._resume_condition = threading.Condition(self._state_lock)
|
|
428
|
+
self._send_lock = threading.Lock()
|
|
429
|
+
self._close_lock = threading.Lock()
|
|
430
|
+
self._ack_condition = threading.Condition()
|
|
431
|
+
self._closed = threading.Event()
|
|
432
|
+
self._cleanup_done = threading.Event()
|
|
433
|
+
self._next_inbound_seq = 0
|
|
434
|
+
self._acked_inbound_seq = 0
|
|
435
|
+
self._last_seq = 0
|
|
436
|
+
self._known_head = 0
|
|
437
|
+
self._pending: dict[int, JsonObject] = {}
|
|
438
|
+
self._missing_since: dict[int, float] = {}
|
|
439
|
+
self._fatal_error: str | None = None
|
|
440
|
+
self._eof_queued = False
|
|
441
|
+
self._stream_dead_at: float | None = None
|
|
442
|
+
self._stream_death_probe_done = False
|
|
443
|
+
self._stream_error: str | None = None
|
|
444
|
+
self._last_stdout_at = time.monotonic()
|
|
445
|
+
self._head_failure_since: float | None = None
|
|
446
|
+
self._last_head_failure_at: float | None = None
|
|
447
|
+
self._next_pid_check_at = time.monotonic() + pid_probe_interval_s
|
|
448
|
+
self._pid_dead_at: float | None = None
|
|
449
|
+
self._last_outbox_error: str | None = None
|
|
450
|
+
self._stream_generation = 0
|
|
451
|
+
self._resuming = False
|
|
452
|
+
self._file_request_timeout_s = file_request_timeout_s
|
|
453
|
+
self._frame_request_timeout_s = frame_request_timeout_s
|
|
454
|
+
self._poll_interval_s = poll_interval_s
|
|
455
|
+
self._stdout_fast_wait_s = stdout_fast_wait_s
|
|
456
|
+
self._stream_death_grace_s = stream_death_grace_s
|
|
457
|
+
self._frame_read_grace_s = frame_read_grace_s
|
|
458
|
+
self._pid_probe_interval_s = pid_probe_interval_s
|
|
459
|
+
self._send_request_timeout_s = send_request_timeout_s
|
|
460
|
+
self._send_ack_timeout_s = send_ack_timeout_s
|
|
461
|
+
self._close_request_timeout_s = close_request_timeout_s
|
|
462
|
+
self._stdout_silence_grace_s = stdout_silence_grace_s
|
|
463
|
+
self._reader = self._new_reader(handle, generation=self._stream_generation)
|
|
464
|
+
self._reader.start()
|
|
465
|
+
|
|
466
|
+
def resume(self, sandbox: SandboxHandle, *, timeout: float | None = 0) -> None:
|
|
467
|
+
"""Reconnect this channel to its preserved runner after an E2B sandbox resume.
|
|
468
|
+
|
|
469
|
+
E2B keeps the runner process and filesystem outbox in a memory-preserving pause, but the
|
|
470
|
+
old command output stream is gone. Reusing this channel preserves both transport sequence
|
|
471
|
+
cursors while replacing only that notification stream. The durable outbox remains the
|
|
472
|
+
source of truth for frames produced while the stream was detached.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
sandbox: The reconnected handle for the same memory-preserved sandbox.
|
|
476
|
+
timeout: E2B command-stream timeout. Zero keeps the live stream attached indefinitely.
|
|
477
|
+
"""
|
|
478
|
+
with self._send_lock, self._close_lock:
|
|
479
|
+
if self._closed.is_set():
|
|
480
|
+
raise RuntimeError("a closed durable runner channel cannot be resumed")
|
|
481
|
+
with self._resume_condition:
|
|
482
|
+
if self._fatal_error is not None:
|
|
483
|
+
raise RuntimeError(
|
|
484
|
+
f"a failed durable runner channel cannot be resumed: {self._fatal_error}"
|
|
485
|
+
)
|
|
486
|
+
self._resuming = True
|
|
487
|
+
self._stream_generation += 1
|
|
488
|
+
generation = self._stream_generation
|
|
489
|
+
previous_handle = self._handle
|
|
490
|
+
try:
|
|
491
|
+
handle = sandbox.commands.connect(self._pid, timeout=timeout)
|
|
492
|
+
except BaseException:
|
|
493
|
+
with self._resume_condition:
|
|
494
|
+
self._resuming = False
|
|
495
|
+
self._resume_condition.notify_all()
|
|
496
|
+
raise
|
|
497
|
+
now = time.monotonic()
|
|
498
|
+
fatal_during_connect: str | None = None
|
|
499
|
+
with self._resume_condition:
|
|
500
|
+
fatal_during_connect = self._fatal_error
|
|
501
|
+
if fatal_during_connect is None:
|
|
502
|
+
self._sandbox = sandbox
|
|
503
|
+
self._handle = handle
|
|
504
|
+
self._stream_dead_at = None
|
|
505
|
+
self._stream_death_probe_done = False
|
|
506
|
+
self._stream_error = None
|
|
507
|
+
self._last_stdout_at = now
|
|
508
|
+
self._head_failure_since = None
|
|
509
|
+
self._last_head_failure_at = None
|
|
510
|
+
self._next_pid_check_at = now + self._pid_probe_interval_s
|
|
511
|
+
self._pid_dead_at = None
|
|
512
|
+
self._last_outbox_error = None
|
|
513
|
+
self._missing_since.clear()
|
|
514
|
+
self._resuming = False
|
|
515
|
+
self._resume_condition.notify_all()
|
|
516
|
+
if fatal_during_connect is not None:
|
|
517
|
+
disconnect = getattr(handle, "disconnect", None)
|
|
518
|
+
if callable(disconnect):
|
|
519
|
+
with contextlib.suppress(Exception):
|
|
520
|
+
disconnect()
|
|
521
|
+
raise RuntimeError(
|
|
522
|
+
f"durable runner channel failed while resuming: {fatal_during_connect}"
|
|
523
|
+
)
|
|
524
|
+
reader = self._new_reader(handle, generation=generation)
|
|
525
|
+
self._reader = reader
|
|
526
|
+
|
|
527
|
+
disconnect = getattr(previous_handle, "disconnect", None)
|
|
528
|
+
if callable(disconnect):
|
|
529
|
+
with contextlib.suppress(Exception):
|
|
530
|
+
disconnect()
|
|
531
|
+
reader.start()
|
|
532
|
+
|
|
533
|
+
def send(self, frame: JsonObject) -> None:
|
|
534
|
+
"""Deliver one host frame once, retrying the same sequence until its durable ack."""
|
|
535
|
+
with self._send_lock:
|
|
536
|
+
if self._closed.is_set():
|
|
537
|
+
raise _E2BChannelSendError("durable runner channel is closed")
|
|
538
|
+
self._next_inbound_seq += 1
|
|
539
|
+
inbound_seq = self._next_inbound_seq
|
|
540
|
+
line = self._encode_inbound(inbound_seq, frame)
|
|
541
|
+
deadline = time.monotonic() + self._send_ack_timeout_s
|
|
542
|
+
last_error: Exception | None = None
|
|
543
|
+
attempts = 0
|
|
544
|
+
|
|
545
|
+
while True:
|
|
546
|
+
if self._inbound_acknowledged(inbound_seq):
|
|
547
|
+
return
|
|
548
|
+
if self._closed.is_set():
|
|
549
|
+
raise _E2BChannelSendError(
|
|
550
|
+
f"durable runner closed before acknowledging inbound frame {inbound_seq}"
|
|
551
|
+
)
|
|
552
|
+
remaining = deadline - time.monotonic()
|
|
553
|
+
if remaining <= 0:
|
|
554
|
+
error = _E2BChannelSendError(
|
|
555
|
+
"durable runner did not acknowledge inbound frame "
|
|
556
|
+
f"{inbound_seq} within {self._send_ack_timeout_s:g}s"
|
|
557
|
+
)
|
|
558
|
+
if last_error is not None:
|
|
559
|
+
raise error from last_error
|
|
560
|
+
raise error
|
|
561
|
+
|
|
562
|
+
# At most two physical writes, always with the exact same sequence. If the first
|
|
563
|
+
# write was accepted but its HTTP response or ack notification was lost, the
|
|
564
|
+
# runner deduplicates the second and republishes the ack without dispatching.
|
|
565
|
+
if attempts < 2:
|
|
566
|
+
attempts += 1
|
|
567
|
+
try:
|
|
568
|
+
self._sandbox.commands.send_stdin(
|
|
569
|
+
self._pid,
|
|
570
|
+
line,
|
|
571
|
+
request_timeout=min(self._send_request_timeout_s, remaining),
|
|
572
|
+
)
|
|
573
|
+
except Exception as exc: # noqa: BLE001 - delivery may still have succeeded
|
|
574
|
+
last_error = exc
|
|
575
|
+
|
|
576
|
+
remaining = deadline - time.monotonic()
|
|
577
|
+
if self._wait_for_inbound_ack(
|
|
578
|
+
inbound_seq,
|
|
579
|
+
timeout=min(self._stdout_fast_wait_s, max(0.0, remaining)),
|
|
580
|
+
):
|
|
581
|
+
return
|
|
582
|
+
remaining = deadline - time.monotonic()
|
|
583
|
+
if remaining <= 0:
|
|
584
|
+
continue
|
|
585
|
+
self._poll_outbox(
|
|
586
|
+
request_timeout=min(self._file_request_timeout_s, remaining),
|
|
587
|
+
frame_request_timeout=min(self._frame_request_timeout_s, remaining),
|
|
588
|
+
)
|
|
589
|
+
if self._inbound_acknowledged(inbound_seq):
|
|
590
|
+
return
|
|
591
|
+
if self._fatal_error is not None:
|
|
592
|
+
raise _E2BChannelSendError(self._fatal_error)
|
|
593
|
+
remaining = deadline - time.monotonic()
|
|
594
|
+
self._wait_for_inbound_ack(
|
|
595
|
+
inbound_seq,
|
|
596
|
+
timeout=min(self._poll_interval_s, max(0.0, remaining)),
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def recv(self, timeout: float | None = None) -> JsonObject | None:
|
|
600
|
+
"""Return the next semantic frame in transport-sequence order.
|
|
601
|
+
|
|
602
|
+
Queue waits are capped by the outbox poll cadence, including during the hello handshake.
|
|
603
|
+
This covers a silently hung stdout iterator as well as an explicit stream error. Every E2B
|
|
604
|
+
head/liveness read gets a request timeout no larger than the caller's remaining deadline;
|
|
605
|
+
exact committed frame reads get their own bounded budget because they can contain a full
|
|
606
|
+
model context and are gzip-compressed by the E2B transport.
|
|
607
|
+
"""
|
|
608
|
+
if self._closed.is_set():
|
|
609
|
+
return None
|
|
610
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
611
|
+
while True:
|
|
612
|
+
if not self._wait_for_resume(deadline):
|
|
613
|
+
raise TimeoutError(
|
|
614
|
+
f"no frame from the pi runner within {timeout}s"
|
|
615
|
+
f"{self._stderr_suffix(include_durable=False)}"
|
|
616
|
+
)
|
|
617
|
+
item = self._get_queued_nowait()
|
|
618
|
+
if item is not None:
|
|
619
|
+
return self._unwrap_item(item)
|
|
620
|
+
|
|
621
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
622
|
+
if remaining is not None and remaining <= 0:
|
|
623
|
+
raise TimeoutError(
|
|
624
|
+
f"no frame from the pi runner within {timeout}s"
|
|
625
|
+
f"{self._stderr_suffix(include_durable=False)}"
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# Stdout is the common, low-latency path. Give its reader one short scheduling window
|
|
629
|
+
# before issuing an E2B filesystem RPC; after an explicit stream failure, skip the
|
|
630
|
+
# window and fall through to the durable source immediately.
|
|
631
|
+
if self._stream_dead_at is None:
|
|
632
|
+
fast_wait = self._stdout_fast_wait_s
|
|
633
|
+
if remaining is not None:
|
|
634
|
+
fast_wait = min(fast_wait, remaining)
|
|
635
|
+
if fast_wait > 0:
|
|
636
|
+
try:
|
|
637
|
+
item = self._frames.get(timeout=fast_wait)
|
|
638
|
+
except queue.Empty:
|
|
639
|
+
pass
|
|
640
|
+
else:
|
|
641
|
+
return self._unwrap_item(item)
|
|
642
|
+
|
|
643
|
+
request_timeout = self._file_request_timeout_s
|
|
644
|
+
if remaining is not None:
|
|
645
|
+
request_timeout = min(request_timeout, max(0.001, remaining))
|
|
646
|
+
self._poll_outbox(request_timeout=request_timeout)
|
|
647
|
+
|
|
648
|
+
item = self._get_queued_nowait()
|
|
649
|
+
if item is not None:
|
|
650
|
+
return self._unwrap_item(item)
|
|
651
|
+
|
|
652
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
653
|
+
if remaining is None or remaining > 0:
|
|
654
|
+
liveness_timeout = self._file_request_timeout_s
|
|
655
|
+
if remaining is not None:
|
|
656
|
+
liveness_timeout = min(liveness_timeout, max(0.001, remaining))
|
|
657
|
+
self._classify_runner_liveness(request_timeout=liveness_timeout)
|
|
658
|
+
|
|
659
|
+
item = self._get_queued_nowait()
|
|
660
|
+
if item is not None:
|
|
661
|
+
return self._unwrap_item(item)
|
|
662
|
+
|
|
663
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
664
|
+
if remaining is not None and remaining <= 0:
|
|
665
|
+
raise TimeoutError(
|
|
666
|
+
f"no frame from the pi runner within {timeout}s"
|
|
667
|
+
f"{self._stderr_suffix(include_durable=False)}"
|
|
668
|
+
)
|
|
669
|
+
wait = (
|
|
670
|
+
self._poll_interval_s
|
|
671
|
+
if remaining is None
|
|
672
|
+
else min(self._poll_interval_s, remaining)
|
|
673
|
+
)
|
|
674
|
+
try:
|
|
675
|
+
item = self._frames.get(timeout=max(0.001, wait))
|
|
676
|
+
except queue.Empty:
|
|
677
|
+
continue
|
|
678
|
+
return self._unwrap_item(item)
|
|
679
|
+
|
|
680
|
+
def _wait_for_resume(self, deadline: float | None) -> bool:
|
|
681
|
+
"""Block unary reads while a new sandbox attachment is being established."""
|
|
682
|
+
with self._resume_condition:
|
|
683
|
+
while self._resuming and not self._closed.is_set():
|
|
684
|
+
if deadline is None:
|
|
685
|
+
self._resume_condition.wait()
|
|
686
|
+
continue
|
|
687
|
+
remaining = deadline - time.monotonic()
|
|
688
|
+
if remaining <= 0:
|
|
689
|
+
return False
|
|
690
|
+
self._resume_condition.wait(timeout=remaining)
|
|
691
|
+
return not self._resuming
|
|
692
|
+
|
|
693
|
+
def close(self) -> None:
|
|
694
|
+
"""Logically close immediately and retire the runner in bounded background cleanup."""
|
|
695
|
+
with self._close_lock:
|
|
696
|
+
if self._closed.is_set():
|
|
697
|
+
return
|
|
698
|
+
# Never wait for a filesystem replay that currently owns `_state_lock`: cancellation
|
|
699
|
+
# must be able to retire the process even if a large exact-frame read is in flight.
|
|
700
|
+
self._closed.set()
|
|
701
|
+
if not self._eof_queued:
|
|
702
|
+
self._eof_queued = True
|
|
703
|
+
self._frames.put(_EOF)
|
|
704
|
+
threading.Thread(
|
|
705
|
+
target=self._cleanup_runner,
|
|
706
|
+
name="e2b-durable-cleanup",
|
|
707
|
+
daemon=True,
|
|
708
|
+
).start()
|
|
709
|
+
|
|
710
|
+
def _cleanup_runner(self) -> None:
|
|
711
|
+
"""Best-effort remote teardown; isolated because the SDK may add a 5s health probe."""
|
|
712
|
+
# Preserve a graceful shutdown when no logical send is in flight, but never wait behind
|
|
713
|
+
# one: PID termination below is the authoritative bounded cleanup path.
|
|
714
|
+
try:
|
|
715
|
+
if self._send_lock.acquire(blocking=False):
|
|
716
|
+
try:
|
|
717
|
+
self._next_inbound_seq += 1
|
|
718
|
+
line = self._encode_inbound(
|
|
719
|
+
self._next_inbound_seq,
|
|
720
|
+
{"type": "shutdown"},
|
|
721
|
+
)
|
|
722
|
+
try:
|
|
723
|
+
self._sandbox.commands.send_stdin(
|
|
724
|
+
self._pid,
|
|
725
|
+
line,
|
|
726
|
+
request_timeout=self._close_request_timeout_s,
|
|
727
|
+
)
|
|
728
|
+
except Exception: # noqa: BLE001 - transport may already be dead
|
|
729
|
+
pass
|
|
730
|
+
finally:
|
|
731
|
+
self._send_lock.release()
|
|
732
|
+
try:
|
|
733
|
+
self._sandbox.commands.kill(
|
|
734
|
+
self._pid,
|
|
735
|
+
request_timeout=self._close_request_timeout_s,
|
|
736
|
+
)
|
|
737
|
+
except Exception: # noqa: BLE001 - the process may already have exited
|
|
738
|
+
pass
|
|
739
|
+
disconnect = getattr(self._handle, "disconnect", None)
|
|
740
|
+
if callable(disconnect):
|
|
741
|
+
with contextlib.suppress(Exception):
|
|
742
|
+
disconnect()
|
|
743
|
+
if threading.current_thread() is not self._reader:
|
|
744
|
+
self._reader.join(timeout=self._close_request_timeout_s)
|
|
745
|
+
finally:
|
|
746
|
+
self._cleanup_done.set()
|
|
747
|
+
|
|
748
|
+
def stderr_tail(self) -> str:
|
|
749
|
+
"""Recent stream diagnostics plus the runner's durable stderr file."""
|
|
750
|
+
self._refresh_durable_stderr(self._file_request_timeout_s)
|
|
751
|
+
lines = [*self._stderr, *self._durable_stderr.splitlines()]
|
|
752
|
+
return "\n".join(lines[-self._stderr_lines :])
|
|
753
|
+
|
|
754
|
+
def _stderr_suffix(self, *, include_durable: bool = True) -> str:
|
|
755
|
+
if include_durable:
|
|
756
|
+
tail = self.stderr_tail()
|
|
757
|
+
else:
|
|
758
|
+
tail = "\n".join(self._stderr)
|
|
759
|
+
return f"; recent runner stderr:\n{tail}" if tail else ""
|
|
760
|
+
|
|
761
|
+
def _get_queued_nowait(self) -> JsonObject | _Eof | None:
|
|
762
|
+
try:
|
|
763
|
+
return self._frames.get_nowait()
|
|
764
|
+
except queue.Empty:
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
def _unwrap_item(self, item: JsonObject | _Eof) -> JsonObject | None:
|
|
768
|
+
if self._closed.is_set():
|
|
769
|
+
return None # cancellation discards every semantic frame queued before teardown
|
|
770
|
+
if not isinstance(item, _Eof):
|
|
771
|
+
return item
|
|
772
|
+
self._frames.put(item) # sticky for every later recv
|
|
773
|
+
if self._closed.is_set():
|
|
774
|
+
return None
|
|
775
|
+
message = self._fatal_error or "pi live runner process exited"
|
|
776
|
+
raise RuntimeError(f"{message}{self._stderr_suffix()}")
|
|
777
|
+
|
|
778
|
+
def _new_reader(self, handle: CommandHandle, *, generation: int) -> threading.Thread:
|
|
779
|
+
return threading.Thread(
|
|
780
|
+
target=self._read_events,
|
|
781
|
+
args=(handle, generation),
|
|
782
|
+
name="e2b-durable-reader",
|
|
783
|
+
daemon=True,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
def _read_events(self, handle: CommandHandle, generation: int) -> None:
|
|
787
|
+
pending = ""
|
|
788
|
+
try:
|
|
789
|
+
for stdout, stderr, _pty in handle:
|
|
790
|
+
if self._closed.is_set():
|
|
791
|
+
return
|
|
792
|
+
with self._state_lock:
|
|
793
|
+
if generation != self._stream_generation:
|
|
794
|
+
return
|
|
795
|
+
if stderr:
|
|
796
|
+
for stderr_line in stderr.splitlines():
|
|
797
|
+
if stderr_line.strip():
|
|
798
|
+
self._stderr.append(stderr_line)
|
|
799
|
+
if stdout:
|
|
800
|
+
self._last_stdout_at = time.monotonic()
|
|
801
|
+
if not stdout:
|
|
802
|
+
continue
|
|
803
|
+
pending += stdout
|
|
804
|
+
while "\n" in pending:
|
|
805
|
+
line, pending = pending.split("\n", 1)
|
|
806
|
+
self._decode_stdout_line(line, generation=generation)
|
|
807
|
+
except Exception as exc: # noqa: BLE001 - stdout is only a fallible notification path
|
|
808
|
+
with self._state_lock:
|
|
809
|
+
if generation == self._stream_generation and not self._closed.is_set():
|
|
810
|
+
self._stream_error = f"[channel] output stream failed: {exc}"
|
|
811
|
+
self._stderr.append(self._stream_error)
|
|
812
|
+
else:
|
|
813
|
+
with self._state_lock:
|
|
814
|
+
if generation == self._stream_generation and not self._closed.is_set():
|
|
815
|
+
self._stream_error = "[channel] output stream ended"
|
|
816
|
+
finally:
|
|
817
|
+
with self._state_lock:
|
|
818
|
+
if generation == self._stream_generation and not self._closed.is_set():
|
|
819
|
+
self._stream_dead_at = time.monotonic()
|
|
820
|
+
|
|
821
|
+
def _decode_stdout_line(self, line: str, *, generation: int) -> None:
|
|
822
|
+
text = line.strip()
|
|
823
|
+
if not text:
|
|
824
|
+
return
|
|
825
|
+
try:
|
|
826
|
+
value = json.loads(base64.b64decode(text, validate=True))
|
|
827
|
+
except ValueError:
|
|
828
|
+
with self._state_lock:
|
|
829
|
+
if generation == self._stream_generation:
|
|
830
|
+
self._stderr.append(f"[stdout] {text}")
|
|
831
|
+
return
|
|
832
|
+
if isinstance(value, dict) and value.get("type") == TRANSPORT_KEEPALIVE_TYPE:
|
|
833
|
+
return # deliberately unsequenced and never persisted by the durable runner
|
|
834
|
+
envelope = self._parse_envelope(value)
|
|
835
|
+
if envelope is None:
|
|
836
|
+
if isinstance(value, dict):
|
|
837
|
+
with self._state_lock:
|
|
838
|
+
if generation != self._stream_generation:
|
|
839
|
+
return
|
|
840
|
+
self._mark_fatal_locked(
|
|
841
|
+
"durable runner emitted an unsequenced or malformed semantic frame"
|
|
842
|
+
)
|
|
843
|
+
return
|
|
844
|
+
with self._state_lock:
|
|
845
|
+
if generation == self._stream_generation:
|
|
846
|
+
self._stderr.append(f"[stdout] invalid durable envelope: {text}")
|
|
847
|
+
return
|
|
848
|
+
seq, frame = envelope
|
|
849
|
+
with self._state_lock:
|
|
850
|
+
if generation != self._stream_generation:
|
|
851
|
+
return
|
|
852
|
+
if seq <= self._last_seq:
|
|
853
|
+
return
|
|
854
|
+
self._pending.setdefault(seq, frame)
|
|
855
|
+
# The runner publishes the exact frame + head before stdout, so the notification is
|
|
856
|
+
# proof that disk has committed through this sequence even if `head` is briefly stale.
|
|
857
|
+
self._known_head = max(self._known_head, seq)
|
|
858
|
+
# Never make the notification thread replay an unbounded disk gap while holding the
|
|
859
|
+
# ordering lock. ``recv`` continues one exact file per bounded poll.
|
|
860
|
+
self._drain_committed_locked(
|
|
861
|
+
request_timeout=self._frame_request_timeout_s,
|
|
862
|
+
max_file_reads=1,
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
def _poll_outbox(
|
|
866
|
+
self,
|
|
867
|
+
*,
|
|
868
|
+
request_timeout: float,
|
|
869
|
+
frame_request_timeout: float | None = None,
|
|
870
|
+
) -> None:
|
|
871
|
+
if self._closed.is_set():
|
|
872
|
+
return
|
|
873
|
+
with self._state_lock:
|
|
874
|
+
if self._resuming:
|
|
875
|
+
return
|
|
876
|
+
generation = self._stream_generation
|
|
877
|
+
sandbox = self._sandbox
|
|
878
|
+
# Head is tiny and latency-sensitive. Exact frames use a separate, gzip-enabled budget:
|
|
879
|
+
# an LLM request can carry the agent's entire context and must not be misclassified as
|
|
880
|
+
# unavailable merely because it cannot fit inside this short polling interval.
|
|
881
|
+
unary_timeout = max(0.001, request_timeout)
|
|
882
|
+
try:
|
|
883
|
+
raw_head = sandbox.files.read(
|
|
884
|
+
f"{self._outbox_root}/head", request_timeout=unary_timeout
|
|
885
|
+
)
|
|
886
|
+
head = int(raw_head.strip())
|
|
887
|
+
if head < 0:
|
|
888
|
+
raise ValueError("negative sequence")
|
|
889
|
+
except Exception as exc: # noqa: BLE001 - startup/momentary unary read misses are normal
|
|
890
|
+
with self._state_lock:
|
|
891
|
+
if generation != self._stream_generation or self._resuming:
|
|
892
|
+
return
|
|
893
|
+
self._last_outbox_error = f"head read failed: {exc}"
|
|
894
|
+
now = time.monotonic()
|
|
895
|
+
# Treat only uninterrupted failures as one outage. AgentProject does not pump this
|
|
896
|
+
# channel while it is idle between proposal iterations, so a long idle gap must not
|
|
897
|
+
# make the next isolated read failure look ancient.
|
|
898
|
+
if self._last_head_failure_at is None or now - self._last_head_failure_at > max(
|
|
899
|
+
1.0, self._poll_interval_s * 4
|
|
900
|
+
):
|
|
901
|
+
self._head_failure_since = now
|
|
902
|
+
self._last_head_failure_at = now
|
|
903
|
+
head_failure_since = self._head_failure_since or now
|
|
904
|
+
output_unavailable = self._stream_dead_at is not None or (
|
|
905
|
+
now - self._last_stdout_at >= self._stdout_silence_grace_s
|
|
906
|
+
)
|
|
907
|
+
if output_unavailable and now - head_failure_since >= self._frame_read_grace_s:
|
|
908
|
+
self._mark_fatal_locked(
|
|
909
|
+
"durable outbox head unavailable after "
|
|
910
|
+
f"{self._frame_read_grace_s:g}s: {self._last_outbox_error}"
|
|
911
|
+
)
|
|
912
|
+
return
|
|
913
|
+
with self._state_lock:
|
|
914
|
+
if generation != self._stream_generation or self._resuming:
|
|
915
|
+
return
|
|
916
|
+
self._head_failure_since = None
|
|
917
|
+
self._last_head_failure_at = None
|
|
918
|
+
self._known_head = max(self._known_head, head)
|
|
919
|
+
self._drain_committed_locked(
|
|
920
|
+
request_timeout=(
|
|
921
|
+
self._frame_request_timeout_s
|
|
922
|
+
if frame_request_timeout is None
|
|
923
|
+
else frame_request_timeout
|
|
924
|
+
),
|
|
925
|
+
max_file_reads=1,
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
def _drain_committed_locked(
|
|
929
|
+
self, *, request_timeout: float, max_file_reads: int | None = None
|
|
930
|
+
) -> None:
|
|
931
|
+
"""Advance the one delivery cursor; caller holds ``_state_lock``."""
|
|
932
|
+
file_reads = 0
|
|
933
|
+
while not self._closed.is_set() and self._fatal_error is None:
|
|
934
|
+
expected = self._last_seq + 1
|
|
935
|
+
frame = self._pending.pop(expected, None)
|
|
936
|
+
if frame is None:
|
|
937
|
+
if expected > self._known_head:
|
|
938
|
+
return
|
|
939
|
+
if max_file_reads is not None and file_reads >= max_file_reads:
|
|
940
|
+
return
|
|
941
|
+
file_reads += 1
|
|
942
|
+
envelope = self._read_frame_file(expected, request_timeout=request_timeout)
|
|
943
|
+
if self._closed.is_set():
|
|
944
|
+
return
|
|
945
|
+
if envelope is None:
|
|
946
|
+
started = self._missing_since.setdefault(expected, time.monotonic())
|
|
947
|
+
if time.monotonic() - started >= self._frame_read_grace_s:
|
|
948
|
+
detail = f": {self._last_outbox_error}" if self._last_outbox_error else ""
|
|
949
|
+
self._mark_fatal_locked(
|
|
950
|
+
f"durable outbox frame {expected} unavailable after "
|
|
951
|
+
f"{self._frame_read_grace_s:g}s{detail}"
|
|
952
|
+
)
|
|
953
|
+
return
|
|
954
|
+
_seq, frame = envelope
|
|
955
|
+
self._missing_since.pop(expected, None)
|
|
956
|
+
self._last_seq = expected
|
|
957
|
+
if frame.get("type") != TRANSPORT_KEEPALIVE_TYPE:
|
|
958
|
+
self._deliver_frame_locked(frame)
|
|
959
|
+
|
|
960
|
+
def _deliver_frame_locked(self, frame: JsonObject) -> None:
|
|
961
|
+
"""Filter transport control frames; caller holds the outbound ordering lock."""
|
|
962
|
+
kind = frame.get("type")
|
|
963
|
+
if kind == "transport_ack":
|
|
964
|
+
inbound_seq = frame.get("transport_in_seq")
|
|
965
|
+
if (
|
|
966
|
+
isinstance(inbound_seq, bool)
|
|
967
|
+
or not isinstance(inbound_seq, int)
|
|
968
|
+
or inbound_seq <= 0
|
|
969
|
+
or inbound_seq > self._next_inbound_seq
|
|
970
|
+
):
|
|
971
|
+
self._mark_fatal_locked("durable runner emitted an invalid inbound acknowledgement")
|
|
972
|
+
return
|
|
973
|
+
with self._ack_condition:
|
|
974
|
+
self._acked_inbound_seq = max(self._acked_inbound_seq, inbound_seq)
|
|
975
|
+
self._ack_condition.notify_all()
|
|
976
|
+
return
|
|
977
|
+
if kind == "transport_nack":
|
|
978
|
+
expected = frame.get("expected_transport_in_seq")
|
|
979
|
+
received = frame.get("transport_in_seq")
|
|
980
|
+
self._mark_fatal_locked(
|
|
981
|
+
f"durable runner rejected inbound sequence {received!r}; expected {expected!r}"
|
|
982
|
+
)
|
|
983
|
+
return
|
|
984
|
+
self._frames.put(frame)
|
|
985
|
+
|
|
986
|
+
@staticmethod
|
|
987
|
+
def _encode_inbound(inbound_seq: int, frame: JsonObject) -> str:
|
|
988
|
+
envelope: JsonObject = {
|
|
989
|
+
"transport_in_seq": inbound_seq,
|
|
990
|
+
"frame": frame,
|
|
991
|
+
}
|
|
992
|
+
return base64.b64encode(json.dumps(envelope).encode("utf-8")).decode("ascii") + "\n"
|
|
993
|
+
|
|
994
|
+
def _inbound_acknowledged(self, inbound_seq: int) -> bool:
|
|
995
|
+
with self._ack_condition:
|
|
996
|
+
return self._acked_inbound_seq >= inbound_seq
|
|
997
|
+
|
|
998
|
+
def _wait_for_inbound_ack(self, inbound_seq: int, *, timeout: float) -> bool:
|
|
999
|
+
with self._ack_condition:
|
|
1000
|
+
if self._acked_inbound_seq >= inbound_seq:
|
|
1001
|
+
return True
|
|
1002
|
+
if timeout > 0:
|
|
1003
|
+
self._ack_condition.wait(timeout=timeout)
|
|
1004
|
+
return self._acked_inbound_seq >= inbound_seq
|
|
1005
|
+
|
|
1006
|
+
def _read_frame_file(
|
|
1007
|
+
self, seq: int, *, request_timeout: float
|
|
1008
|
+
) -> tuple[int, JsonObject] | None:
|
|
1009
|
+
path = f"{self._outbox_root}/frames/{seq:020d}.json"
|
|
1010
|
+
try:
|
|
1011
|
+
raw = self._sandbox.files.read(
|
|
1012
|
+
path,
|
|
1013
|
+
request_timeout=request_timeout,
|
|
1014
|
+
gzip=True,
|
|
1015
|
+
)
|
|
1016
|
+
value = json.loads(raw)
|
|
1017
|
+
envelope = self._parse_envelope(value)
|
|
1018
|
+
if envelope is None or envelope[0] != seq:
|
|
1019
|
+
raise ValueError(f"expected transport_seq {seq}")
|
|
1020
|
+
return envelope
|
|
1021
|
+
except Exception as exc: # noqa: BLE001 - retried by subsequent bounded recv polls
|
|
1022
|
+
self._last_outbox_error = f"{path} read failed: {exc}"
|
|
1023
|
+
return None
|
|
1024
|
+
|
|
1025
|
+
def _classify_runner_liveness(self, *, request_timeout: float) -> None:
|
|
1026
|
+
with self._state_lock:
|
|
1027
|
+
if self._closed.is_set() or self._resuming:
|
|
1028
|
+
return
|
|
1029
|
+
generation = self._stream_generation
|
|
1030
|
+
sandbox = self._sandbox
|
|
1031
|
+
dead_at = self._stream_dead_at
|
|
1032
|
+
now = time.monotonic()
|
|
1033
|
+
stream_failure_is_due = (
|
|
1034
|
+
dead_at is not None
|
|
1035
|
+
and not self._stream_death_probe_done
|
|
1036
|
+
and now - dead_at >= self._stream_death_grace_s
|
|
1037
|
+
)
|
|
1038
|
+
if not stream_failure_is_due and now < self._next_pid_check_at:
|
|
1039
|
+
return
|
|
1040
|
+
if stream_failure_is_due:
|
|
1041
|
+
self._stream_death_probe_done = True
|
|
1042
|
+
self._next_pid_check_at = now + self._pid_probe_interval_s
|
|
1043
|
+
try:
|
|
1044
|
+
processes = sandbox.commands.list(request_timeout=request_timeout)
|
|
1045
|
+
except Exception as exc: # noqa: BLE001 - a failed probe is not proof of process death
|
|
1046
|
+
message = f"[channel] runner liveness probe failed: {exc}"
|
|
1047
|
+
with self._state_lock:
|
|
1048
|
+
if generation != self._stream_generation or self._resuming:
|
|
1049
|
+
return
|
|
1050
|
+
if not self._stderr or self._stderr[-1] != message:
|
|
1051
|
+
self._stderr.append(message)
|
|
1052
|
+
return
|
|
1053
|
+
with self._state_lock:
|
|
1054
|
+
if generation != self._stream_generation or self._resuming:
|
|
1055
|
+
return
|
|
1056
|
+
if any(process.pid == self._pid for process in processes):
|
|
1057
|
+
self._pid_dead_at = None
|
|
1058
|
+
return
|
|
1059
|
+
# `recv` polls disk before every liveness probe. Keep doing so for a bounded grace after
|
|
1060
|
+
# the PID disappears, allowing a final frame and head update to become visible before
|
|
1061
|
+
# EOF.
|
|
1062
|
+
if self._pid_dead_at is None:
|
|
1063
|
+
self._pid_dead_at = now
|
|
1064
|
+
return
|
|
1065
|
+
if now - self._pid_dead_at < self._frame_read_grace_s:
|
|
1066
|
+
return
|
|
1067
|
+
self._refresh_durable_stderr(
|
|
1068
|
+
request_timeout,
|
|
1069
|
+
generation=generation,
|
|
1070
|
+
sandbox=sandbox,
|
|
1071
|
+
)
|
|
1072
|
+
with self._state_lock:
|
|
1073
|
+
if generation != self._stream_generation or self._resuming:
|
|
1074
|
+
return
|
|
1075
|
+
detail = f" after {self._stream_error}" if self._stream_error else ""
|
|
1076
|
+
self._mark_fatal_locked(f"pi live runner process exited{detail}")
|
|
1077
|
+
|
|
1078
|
+
def _refresh_durable_stderr(
|
|
1079
|
+
self,
|
|
1080
|
+
request_timeout: float,
|
|
1081
|
+
*,
|
|
1082
|
+
generation: int | None = None,
|
|
1083
|
+
sandbox: SandboxHandle | None = None,
|
|
1084
|
+
) -> None:
|
|
1085
|
+
if generation is None or sandbox is None:
|
|
1086
|
+
with self._state_lock:
|
|
1087
|
+
if self._resuming:
|
|
1088
|
+
return
|
|
1089
|
+
generation = self._stream_generation
|
|
1090
|
+
sandbox = self._sandbox
|
|
1091
|
+
try:
|
|
1092
|
+
stderr = sandbox.files.read(self._stderr_path, request_timeout=request_timeout)
|
|
1093
|
+
except Exception: # noqa: BLE001 - diagnostics must never mask the transport error
|
|
1094
|
+
return
|
|
1095
|
+
with self._state_lock:
|
|
1096
|
+
if generation == self._stream_generation and not self._resuming:
|
|
1097
|
+
self._durable_stderr = "\n".join(stderr.splitlines()[-self._stderr_lines :])
|
|
1098
|
+
|
|
1099
|
+
@staticmethod
|
|
1100
|
+
def _parse_envelope(value: object) -> tuple[int, JsonObject] | None:
|
|
1101
|
+
if not isinstance(value, dict):
|
|
1102
|
+
return None
|
|
1103
|
+
seq = value.get("transport_seq")
|
|
1104
|
+
frame = value.get("frame")
|
|
1105
|
+
if isinstance(seq, bool) or not isinstance(seq, int) or seq <= 0:
|
|
1106
|
+
return None
|
|
1107
|
+
if not isinstance(frame, dict):
|
|
1108
|
+
return None
|
|
1109
|
+
return seq, cast("JsonObject", frame)
|
|
1110
|
+
|
|
1111
|
+
def _mark_fatal_locked(self, message: str) -> None:
|
|
1112
|
+
if self._fatal_error is None:
|
|
1113
|
+
self._fatal_error = message
|
|
1114
|
+
self._queue_eof_locked()
|
|
1115
|
+
|
|
1116
|
+
def _queue_eof_locked(self) -> None:
|
|
1117
|
+
if not self._eof_queued:
|
|
1118
|
+
self._eof_queued = True
|
|
1119
|
+
self._frames.put(_EOF)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
class E2BSandboxPool:
|
|
1123
|
+
"""Bootstrapped sandboxes with a running pi runner, reused within a proposal batch.
|
|
1124
|
+
|
|
1125
|
+
The bootstrap (runner files + node 22 + pi's npm deps unless a template prebakes them) is
|
|
1126
|
+
doc-independent — a mutated harness's code surfaces travel per-episode in
|
|
1127
|
+
`episode_start.files` — so one pool serves a whole search. `create_harness` retires the idle
|
|
1128
|
+
pool at each iteration boundary before the proposer runs, preventing long
|
|
1129
|
+
proposer/evaluation gaps from leaving stale E2B command streams alive; score waves for
|
|
1130
|
+
sibling proposals in that iteration
|
|
1131
|
+
still reuse warm sandboxes instead of re-paying installs. Concurrent episodes acquire distinct
|
|
1132
|
+
sandboxes; a sandbox whose episode raised is discarded (the runner process is in an unknown
|
|
1133
|
+
state — reuse could cross frames between episodes). `close()` kills everything; the pool lock
|
|
1134
|
+
guards only the free lists, so parallel bootstraps never serialize behind one sandbox's
|
|
1135
|
+
multi-minute npm install.
|
|
1136
|
+
"""
|
|
1137
|
+
|
|
1138
|
+
def __init__(
|
|
1139
|
+
self,
|
|
1140
|
+
*,
|
|
1141
|
+
template: str | None = None,
|
|
1142
|
+
api_key: str | None = None,
|
|
1143
|
+
metadata: dict[str, str] | None = None,
|
|
1144
|
+
sandbox_factory: SandboxFactory | None = None,
|
|
1145
|
+
hello_timeout: float = HELLO_TIMEOUT_S,
|
|
1146
|
+
episode_timeout_s: float = DEFAULT_EVAL_EPISODE_TIMEOUT_S,
|
|
1147
|
+
) -> None:
|
|
1148
|
+
self._episode_timeout_s = validate_episode_timeout_s(episode_timeout_s)
|
|
1149
|
+
lease_lifetime_s = _episode_lease_lifetime_s(self._episode_timeout_s)
|
|
1150
|
+
self._sandbox_timeout_s = max(
|
|
1151
|
+
math.ceil(DEFAULT_SANDBOX_TIMEOUT_S),
|
|
1152
|
+
math.ceil(lease_lifetime_s),
|
|
1153
|
+
)
|
|
1154
|
+
# The hard 3600s cap alone would kill a legitimately long episode; the cap must always
|
|
1155
|
+
# cover the lease sized for this pool's episode budget.
|
|
1156
|
+
self._max_episode_lifetime_s = max(
|
|
1157
|
+
MAX_EVAL_EPISODE_LIFETIME_S,
|
|
1158
|
+
lease_lifetime_s,
|
|
1159
|
+
)
|
|
1160
|
+
self._template = resolve_e2b_template(template)
|
|
1161
|
+
self._factory = sandbox_factory or default_sandbox_factory(
|
|
1162
|
+
api_key=api_key,
|
|
1163
|
+
template=self._template if self._template is not None else "",
|
|
1164
|
+
metadata=metadata,
|
|
1165
|
+
timeout=self._sandbox_timeout_s,
|
|
1166
|
+
)
|
|
1167
|
+
self._hello_timeout = hello_timeout
|
|
1168
|
+
self._lock = threading.Lock()
|
|
1169
|
+
self._idle: list[tuple[SandboxHandle, E2BStdioChannel]] = []
|
|
1170
|
+
self._all: list[SandboxHandle] = []
|
|
1171
|
+
self._closed = False
|
|
1172
|
+
# Usage meter: lifetime seconds accumulate into _retired_seconds when a sandbox dies;
|
|
1173
|
+
# live sandboxes are counted from their _started stamp. Keyed by id() — SandboxHandle is
|
|
1174
|
+
# a protocol, not hashable-by-contract.
|
|
1175
|
+
self._started: dict[int, float] = {}
|
|
1176
|
+
# An exact lease can be reached concurrently by pool.close() and an in-flight episode's
|
|
1177
|
+
# release(). The event makes one caller own each kill attempt while every other caller
|
|
1178
|
+
# waits for its proof before deciding whether a retry is still needed.
|
|
1179
|
+
self._retiring: dict[int, threading.Event] = {}
|
|
1180
|
+
self._created = 0
|
|
1181
|
+
self._retired_seconds = 0.0
|
|
1182
|
+
|
|
1183
|
+
def acquire(self) -> tuple[SandboxHandle, E2BStdioChannel]:
|
|
1184
|
+
"""An idle (bootstrapped, hello-verified) sandbox+channel; creates one when none is free.
|
|
1185
|
+
|
|
1186
|
+
Reused sandboxes get their lifetime EXTENDED first (`set_timeout` restarts E2B's
|
|
1187
|
+
countdown): sandboxes created at a search's first wave must survive every later wave,
|
|
1188
|
+
and a long search otherwise outlives the fixed creation-time cap — the runner stream
|
|
1189
|
+
drops mid-episode ("Server disconnected"). A reused sandbox whose extension fails is
|
|
1190
|
+
already dead (idle past its cap); it is retired and the next one is tried.
|
|
1191
|
+
"""
|
|
1192
|
+
while True:
|
|
1193
|
+
with self._lock:
|
|
1194
|
+
if self._closed:
|
|
1195
|
+
raise RuntimeError("E2BSandboxPool is closed")
|
|
1196
|
+
if not self._idle:
|
|
1197
|
+
break
|
|
1198
|
+
sandbox, channel = self._idle.pop()
|
|
1199
|
+
try:
|
|
1200
|
+
sandbox.set_timeout(self._sandbox_timeout_s)
|
|
1201
|
+
except Exception: # noqa: BLE001 - a dead idle sandbox is expected after long gaps
|
|
1202
|
+
self._retire(sandbox)
|
|
1203
|
+
continue
|
|
1204
|
+
return sandbox, channel
|
|
1205
|
+
sandbox = create_sandbox(self._factory)
|
|
1206
|
+
with self._lock:
|
|
1207
|
+
self._created += 1
|
|
1208
|
+
self._started[id(sandbox)] = time.monotonic()
|
|
1209
|
+
if not self._closed:
|
|
1210
|
+
# Register before bootstrap so cancellation can kill cold-start sandboxes that
|
|
1211
|
+
# are still installing dependencies or waiting for the runner hello.
|
|
1212
|
+
self._all.append(sandbox)
|
|
1213
|
+
registered = True
|
|
1214
|
+
else:
|
|
1215
|
+
registered = False
|
|
1216
|
+
if not registered:
|
|
1217
|
+
self._retire(sandbox)
|
|
1218
|
+
raise RuntimeError("E2BSandboxPool is closed")
|
|
1219
|
+
try:
|
|
1220
|
+
self._bootstrap(sandbox)
|
|
1221
|
+
channel = self._start_runner(sandbox)
|
|
1222
|
+
except BaseException:
|
|
1223
|
+
self._retire(sandbox)
|
|
1224
|
+
raise
|
|
1225
|
+
with self._lock:
|
|
1226
|
+
if not self._closed:
|
|
1227
|
+
return sandbox, channel
|
|
1228
|
+
self._retire(sandbox) # closed while we were bootstrapping: don't leak the sandbox
|
|
1229
|
+
raise RuntimeError("E2BSandboxPool is closed")
|
|
1230
|
+
|
|
1231
|
+
def assert_episode_timeout(self, episode_timeout_s: float) -> None:
|
|
1232
|
+
"""Reject a runtime budget that this shared pool cannot keep alive."""
|
|
1233
|
+
requested = validate_episode_timeout_s(episode_timeout_s)
|
|
1234
|
+
required_lifetime = _episode_lease_lifetime_s(requested)
|
|
1235
|
+
required_lease = max(math.ceil(DEFAULT_SANDBOX_TIMEOUT_S), math.ceil(required_lifetime))
|
|
1236
|
+
if (
|
|
1237
|
+
self._sandbox_timeout_s < required_lease
|
|
1238
|
+
or self._max_episode_lifetime_s < required_lifetime
|
|
1239
|
+
):
|
|
1240
|
+
raise ValueError(
|
|
1241
|
+
"shared E2BSandboxPool episode timeout is too small; construct the pool "
|
|
1242
|
+
f"with episode_timeout_s >= {requested:g}"
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
def release(self, sandbox: SandboxHandle, channel: E2BStdioChannel, *, healthy: bool) -> None:
|
|
1246
|
+
"""Return a sandbox for reuse, or discard it after a failed episode."""
|
|
1247
|
+
if healthy:
|
|
1248
|
+
with self._lock:
|
|
1249
|
+
if not self._closed:
|
|
1250
|
+
self._idle.append((sandbox, channel))
|
|
1251
|
+
return
|
|
1252
|
+
self._retire(sandbox)
|
|
1253
|
+
|
|
1254
|
+
def retire_idle(self) -> int:
|
|
1255
|
+
"""Retire every sandbox currently idle, preserving any episode still in flight.
|
|
1256
|
+
|
|
1257
|
+
The idle set is detached atomically so a concurrent acquire cannot reclaim a stream that
|
|
1258
|
+
this call is about to kill. Teardown happens outside the pool lock and in a bounded worker
|
|
1259
|
+
set: a normal E2B evaluation wave can leave dozens of idle sandboxes, and serial network
|
|
1260
|
+
teardown would otherwise add material latency before every proposer call. Usage remains
|
|
1261
|
+
cumulative because each sandbox still passes through ``_retire`` exactly once.
|
|
1262
|
+
|
|
1263
|
+
Returns the number retired, primarily for diagnostics and tests.
|
|
1264
|
+
"""
|
|
1265
|
+
with self._lock:
|
|
1266
|
+
idle, self._idle = self._idle, []
|
|
1267
|
+
sandboxes = [sandbox for sandbox, _channel in idle]
|
|
1268
|
+
self._retire_many(sandboxes)
|
|
1269
|
+
return len(sandboxes)
|
|
1270
|
+
|
|
1271
|
+
def close(self) -> None:
|
|
1272
|
+
"""Kill every pooled sandbox; safe to call more than once."""
|
|
1273
|
+
with self._lock:
|
|
1274
|
+
self._closed = True
|
|
1275
|
+
sandboxes = list(self._all)
|
|
1276
|
+
self._idle = []
|
|
1277
|
+
self._retire_many(sandboxes)
|
|
1278
|
+
|
|
1279
|
+
def usage(self) -> SandboxUsage:
|
|
1280
|
+
"""The pool's spend meter so far: sandbox count and total lifetime seconds."""
|
|
1281
|
+
now = time.monotonic()
|
|
1282
|
+
with self._lock:
|
|
1283
|
+
live = sum(now - started for started in self._started.values())
|
|
1284
|
+
return SandboxUsage(count=self._created, seconds=self._retired_seconds + live)
|
|
1285
|
+
|
|
1286
|
+
def _retire(self, sandbox: SandboxHandle) -> None:
|
|
1287
|
+
"""Kill one lease, finalizing its meter only after teardown is proved."""
|
|
1288
|
+
sandbox_id = id(sandbox)
|
|
1289
|
+
while True:
|
|
1290
|
+
with self._lock:
|
|
1291
|
+
if sandbox_id not in self._started:
|
|
1292
|
+
return # another close/release path proved this exact lease gone
|
|
1293
|
+
owner_done = self._retiring.get(sandbox_id)
|
|
1294
|
+
if owner_done is None:
|
|
1295
|
+
owner_done = threading.Event()
|
|
1296
|
+
self._retiring[sandbox_id] = owner_done
|
|
1297
|
+
# Never make a lease available again once retirement begins. Keep it in
|
|
1298
|
+
# _all until kill succeeds so a later close() can retry a failed cleanup.
|
|
1299
|
+
self._idle = [item for item in self._idle if item[0] is not sandbox]
|
|
1300
|
+
break
|
|
1301
|
+
owner_done.wait()
|
|
1302
|
+
|
|
1303
|
+
try:
|
|
1304
|
+
kill_sandbox(sandbox)
|
|
1305
|
+
retired_at = time.monotonic()
|
|
1306
|
+
with self._lock:
|
|
1307
|
+
started = self._started.pop(sandbox_id)
|
|
1308
|
+
self._all = [item for item in self._all if item is not sandbox]
|
|
1309
|
+
self._retired_seconds += retired_at - started
|
|
1310
|
+
finally:
|
|
1311
|
+
with self._lock:
|
|
1312
|
+
self._retiring.pop(sandbox_id, None)
|
|
1313
|
+
owner_done.set()
|
|
1314
|
+
|
|
1315
|
+
def _retire_many(self, sandboxes: list[SandboxHandle]) -> None:
|
|
1316
|
+
"""Retire every requested lease and report all unproven cleanup as one failure."""
|
|
1317
|
+
if not sandboxes:
|
|
1318
|
+
return
|
|
1319
|
+
|
|
1320
|
+
def retire(sandbox: SandboxHandle) -> SandboxCleanupError | None:
|
|
1321
|
+
try:
|
|
1322
|
+
self._retire(sandbox)
|
|
1323
|
+
except SandboxCleanupError as error:
|
|
1324
|
+
return error
|
|
1325
|
+
return None
|
|
1326
|
+
|
|
1327
|
+
if len(sandboxes) == 1:
|
|
1328
|
+
failures = [retire(sandboxes[0])]
|
|
1329
|
+
else:
|
|
1330
|
+
# Cancellation commonly lands during a full-concurrency eval wave. Retiring every
|
|
1331
|
+
# active lease in parallel keeps teardown latency bounded by one E2B retry sequence.
|
|
1332
|
+
with ThreadPoolExecutor(max_workers=min(_MAX_RETIRE_WORKERS, len(sandboxes))) as pool:
|
|
1333
|
+
failures = list(pool.map(retire, sandboxes))
|
|
1334
|
+
errors = [error for error in failures if error is not None]
|
|
1335
|
+
if errors:
|
|
1336
|
+
raise SandboxCleanupError(
|
|
1337
|
+
f"failed to prove cleanup for {len(errors)} of {len(sandboxes)} E2B sandboxes",
|
|
1338
|
+
resource="evaluator_sandbox_pool",
|
|
1339
|
+
sandbox_usage=self.usage(),
|
|
1340
|
+
) from errors[0]
|
|
1341
|
+
|
|
1342
|
+
def __enter__(self) -> E2BSandboxPool:
|
|
1343
|
+
return self
|
|
1344
|
+
|
|
1345
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
1346
|
+
self.close()
|
|
1347
|
+
|
|
1348
|
+
def _bootstrap(self, sandbox: SandboxHandle) -> None:
|
|
1349
|
+
"""Upload the runner files; on template-less sandboxes also install node 22 + pi's deps."""
|
|
1350
|
+
for name in _RUNNER_FILES:
|
|
1351
|
+
sandbox.files.write(f"{RUNNER_WORKDIR}/{name}", _read_entry(name))
|
|
1352
|
+
if self._template:
|
|
1353
|
+
return # the template prebakes node 22 + node_modules; only the runner files refresh
|
|
1354
|
+
sandbox.files.write(f"{RUNNER_WORKDIR}/package.json", _PACKAGE_JSON)
|
|
1355
|
+
sandbox.commands.run(NODE_INSTALL_CMD, timeout=INSTALL_TIMEOUT_S)
|
|
1356
|
+
sandbox.commands.run(
|
|
1357
|
+
f"cd {RUNNER_WORKDIR} && npm install {' '.join(PI_NPM_PACKAGES)}",
|
|
1358
|
+
timeout=INSTALL_TIMEOUT_S,
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
def _start_runner(self, sandbox: SandboxHandle) -> E2BStdioChannel:
|
|
1362
|
+
# Template-less bootstrap can consume most of the creation lease. Reset immediately before
|
|
1363
|
+
# starting the runner so startup + hello get the configured budget, then reset again after
|
|
1364
|
+
# hello so the first episode gets that full budget too. Subsequent in-flight heartbeats
|
|
1365
|
+
# keep extending the lease while an episode is active.
|
|
1366
|
+
timeout = self._sandbox_timeout_s
|
|
1367
|
+
sandbox.set_timeout(timeout)
|
|
1368
|
+
# timeout=0 = no command-connection limit (SDK-documented): the runner must outlive every
|
|
1369
|
+
# episode on this sandbox; the sandbox's own lifetime is the real bound.
|
|
1370
|
+
handle = sandbox.commands.run(START_CMD, background=True, stdin=True, timeout=0)
|
|
1371
|
+
# background=True always yields a handle; the union return type is the protocol's.
|
|
1372
|
+
channel = E2BStdioChannel(
|
|
1373
|
+
sandbox,
|
|
1374
|
+
cast("CommandHandle", handle),
|
|
1375
|
+
sandbox_timeout_s=timeout,
|
|
1376
|
+
max_episode_lifetime_s=self._max_episode_lifetime_s,
|
|
1377
|
+
)
|
|
1378
|
+
self._await_hello(channel)
|
|
1379
|
+
sandbox.set_timeout(timeout)
|
|
1380
|
+
return channel
|
|
1381
|
+
|
|
1382
|
+
def _await_hello(self, channel: E2BStdioChannel) -> None:
|
|
1383
|
+
"""Block until the runner's `hello` frame (unknown frames are skipped, RunnerLink-style)."""
|
|
1384
|
+
deadline = time.monotonic() + self._hello_timeout
|
|
1385
|
+
while True:
|
|
1386
|
+
remaining = deadline - time.monotonic()
|
|
1387
|
+
if remaining <= 0:
|
|
1388
|
+
raise RuntimeError(_no_hello(self._hello_timeout, channel))
|
|
1389
|
+
try:
|
|
1390
|
+
frame = channel.recv(timeout=remaining)
|
|
1391
|
+
except TimeoutError as exc:
|
|
1392
|
+
raise RuntimeError(_no_hello(self._hello_timeout, channel)) from exc
|
|
1393
|
+
if frame is None:
|
|
1394
|
+
raise RuntimeError(_no_hello(self._hello_timeout, channel))
|
|
1395
|
+
if frame.get("type") == "hello":
|
|
1396
|
+
return
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
class E2BPiRuntime:
|
|
1400
|
+
"""The `Runtime` for pi-node harnesses on the e2b backend: pi runs inside a pooled sandbox.
|
|
1401
|
+
|
|
1402
|
+
`run` accepts ANY `AgentEnvironment` — tool calls are answered host-side (the world-model
|
|
1403
|
+
simulation in closed-loop eval), while the harness process executes in an E2B sandbox drawn
|
|
1404
|
+
from the pool. Each episode acquires a sandbox, delegates wholly to `RunnerLink` (frame
|
|
1405
|
+
broker, worker-LLM answering, tool budget, transcript recording), and returns the sandbox for
|
|
1406
|
+
reuse. Pass a shared `pool` to amortize bootstrap across many runtimes (a whole harness
|
|
1407
|
+
search); without one, the runtime creates a private pool from `template` and owns its
|
|
1408
|
+
lifetime — call `close()` (or use as a context manager) when the eval finishes.
|
|
1409
|
+
"""
|
|
1410
|
+
|
|
1411
|
+
def __init__(
|
|
1412
|
+
self,
|
|
1413
|
+
*,
|
|
1414
|
+
provider: ToolCallingProvider,
|
|
1415
|
+
files: dict[str, str],
|
|
1416
|
+
tools: list[ToolSpec],
|
|
1417
|
+
system_prompt: str,
|
|
1418
|
+
template: str | None = None,
|
|
1419
|
+
api_key: str | None = None,
|
|
1420
|
+
pool: E2BSandboxPool | None = None,
|
|
1421
|
+
worker_fn: WorkerFn | None = None,
|
|
1422
|
+
hello_timeout: float = HELLO_TIMEOUT_S,
|
|
1423
|
+
max_turns: int = DEFAULT_MAX_TURNS,
|
|
1424
|
+
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
|
|
1425
|
+
temperature: float = 0.7,
|
|
1426
|
+
skills: SkillLibrary | None = None,
|
|
1427
|
+
episode_timeout_s: float = DEFAULT_EVAL_EPISODE_TIMEOUT_S,
|
|
1428
|
+
context_window: int | None = None,
|
|
1429
|
+
transport_retries: int = 1,
|
|
1430
|
+
should_cancel: Callable[[], bool] | None = None,
|
|
1431
|
+
) -> None:
|
|
1432
|
+
if max_turns < 1:
|
|
1433
|
+
raise ValueError("max_turns must be >= 1")
|
|
1434
|
+
if max_output_tokens < 1:
|
|
1435
|
+
raise ValueError("max_output_tokens must be >= 1")
|
|
1436
|
+
if not 0.0 <= temperature <= 2.0:
|
|
1437
|
+
raise ValueError("temperature must be in [0, 2]")
|
|
1438
|
+
episode_timeout_s = validate_episode_timeout_s(episode_timeout_s)
|
|
1439
|
+
if (
|
|
1440
|
+
isinstance(transport_retries, bool)
|
|
1441
|
+
or not isinstance(transport_retries, int)
|
|
1442
|
+
or transport_retries < 0
|
|
1443
|
+
):
|
|
1444
|
+
raise ValueError("transport_retries must be a nonnegative integer")
|
|
1445
|
+
self._provider = provider
|
|
1446
|
+
self._files = dict(files)
|
|
1447
|
+
self._tools = list(tools)
|
|
1448
|
+
self._system_prompt = system_prompt
|
|
1449
|
+
self._worker_fn = worker_fn # test seam, exactly like RunnerLink's
|
|
1450
|
+
self._max_turns = max_turns
|
|
1451
|
+
self._max_output_tokens = max_output_tokens
|
|
1452
|
+
self._temperature = temperature
|
|
1453
|
+
self._skills = skills if skills is not None else SkillLibrary()
|
|
1454
|
+
self._episode_timeout_s = episode_timeout_s
|
|
1455
|
+
self._context_window = context_window
|
|
1456
|
+
self._transport_retries = transport_retries
|
|
1457
|
+
self._should_cancel = should_cancel
|
|
1458
|
+
self._aborted = threading.Event()
|
|
1459
|
+
self._owns_pool = pool is None
|
|
1460
|
+
if pool is None:
|
|
1461
|
+
self._pool = E2BSandboxPool(
|
|
1462
|
+
template=template,
|
|
1463
|
+
api_key=api_key,
|
|
1464
|
+
hello_timeout=hello_timeout,
|
|
1465
|
+
episode_timeout_s=episode_timeout_s,
|
|
1466
|
+
)
|
|
1467
|
+
else:
|
|
1468
|
+
pool.assert_episode_timeout(episode_timeout_s)
|
|
1469
|
+
self._pool = pool
|
|
1470
|
+
|
|
1471
|
+
def run(self, task_id: str, instruction: str, environment: AgentEnvironment) -> RunResult:
|
|
1472
|
+
if self._cancel_requested():
|
|
1473
|
+
raise RuntimeCancelled("runtime episode cancelled")
|
|
1474
|
+
transport_failures = 0
|
|
1475
|
+
while True:
|
|
1476
|
+
try:
|
|
1477
|
+
return self._run_episode(task_id, instruction, environment)
|
|
1478
|
+
except RuntimeCancelled:
|
|
1479
|
+
# RunnerLink owns the episode's authoritative partial worker meter.
|
|
1480
|
+
# Preserve it instead of replacing the cancellation at this wrapper.
|
|
1481
|
+
raise
|
|
1482
|
+
except Exception as exc:
|
|
1483
|
+
if self._cancel_requested():
|
|
1484
|
+
raise RuntimeCancelled("runtime episode cancelled") from exc
|
|
1485
|
+
if not _is_retryable_transport_error(exc):
|
|
1486
|
+
raise
|
|
1487
|
+
if transport_failures >= self._transport_retries:
|
|
1488
|
+
raise
|
|
1489
|
+
# Transport death (stream drop, sandbox lifetime, dead runner): the failed
|
|
1490
|
+
# attempt's sandbox was already discarded on release. Replay on a fresh
|
|
1491
|
+
# sandbox up to the configured budget: the environment session may replay the
|
|
1492
|
+
# dead attempt's opening steps, which for the world-model sim beats failing a
|
|
1493
|
+
# whole search wave over one dropped connection, while a side-effectful real
|
|
1494
|
+
# environment sets transport_retries=0 to forbid replay. pi-level failures
|
|
1495
|
+
# come back as RunResults (episode_error), never as exceptions, so this
|
|
1496
|
+
# retries infrastructure only.
|
|
1497
|
+
transport_failures += 1
|
|
1498
|
+
|
|
1499
|
+
def _run_episode(
|
|
1500
|
+
self, task_id: str, instruction: str, environment: AgentEnvironment
|
|
1501
|
+
) -> RunResult:
|
|
1502
|
+
sandbox, channel = self._pool.acquire()
|
|
1503
|
+
healthy = False
|
|
1504
|
+
try:
|
|
1505
|
+
link = RunnerLink(
|
|
1506
|
+
channel,
|
|
1507
|
+
tools=self._tools,
|
|
1508
|
+
provider=self._provider,
|
|
1509
|
+
worker_fn=self._worker_fn,
|
|
1510
|
+
files=self._files,
|
|
1511
|
+
system_prompt=self._system_prompt,
|
|
1512
|
+
max_turns=self._max_turns,
|
|
1513
|
+
max_output_tokens=self._max_output_tokens,
|
|
1514
|
+
temperature=self._temperature,
|
|
1515
|
+
skills=self._skills,
|
|
1516
|
+
episode_timeout_s=self._episode_timeout_s,
|
|
1517
|
+
context_window=self._context_window,
|
|
1518
|
+
should_cancel=self._cancel_requested,
|
|
1519
|
+
)
|
|
1520
|
+
result = link.run(task_id, instruction, environment)
|
|
1521
|
+
# A wall-budget stop may leave the runner mid-turn. Never reuse or retry it: the
|
|
1522
|
+
# partial result is scoreable, but the sandbox's protocol state is not trustworthy.
|
|
1523
|
+
healthy = result.stop_reason is not StopReason.BUDGET
|
|
1524
|
+
return result
|
|
1525
|
+
finally:
|
|
1526
|
+
self._pool.release(sandbox, channel, healthy=healthy)
|
|
1527
|
+
|
|
1528
|
+
def abort(self) -> None:
|
|
1529
|
+
"""Stop every sibling episode and close the shared pool before its caller joins them."""
|
|
1530
|
+
self._aborted.set()
|
|
1531
|
+
self._pool.close()
|
|
1532
|
+
|
|
1533
|
+
def _cancel_requested(self) -> bool:
|
|
1534
|
+
return self._aborted.is_set() or (self._should_cancel is not None and self._should_cancel())
|
|
1535
|
+
|
|
1536
|
+
def close(self) -> None:
|
|
1537
|
+
"""Close the private pool; a no-op when the pool is shared (its owner closes it)."""
|
|
1538
|
+
if self._owns_pool:
|
|
1539
|
+
self._pool.close()
|
|
1540
|
+
|
|
1541
|
+
def __enter__(self) -> E2BPiRuntime:
|
|
1542
|
+
return self
|
|
1543
|
+
|
|
1544
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
1545
|
+
self.close()
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def _is_retryable_transport_error(exc: Exception) -> bool:
|
|
1549
|
+
"""Whether an episode exception means its E2B transport is no longer trustworthy."""
|
|
1550
|
+
if isinstance(exc, RuntimeCancelled):
|
|
1551
|
+
return False
|
|
1552
|
+
if isinstance(exc, (RuntimeError, TimeoutError)):
|
|
1553
|
+
return True
|
|
1554
|
+
# Keep the E2B SDK optional at import time. Its TimeoutException is not a built-in
|
|
1555
|
+
# TimeoutError, but any instance means the sandbox/channel state is uncertain.
|
|
1556
|
+
exc_type = type(exc)
|
|
1557
|
+
return exc_type.__module__ == "e2b.exceptions" and exc_type.__name__ == "TimeoutException"
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _is_httpcore_remote_stream_reset(exc: Exception) -> bool:
|
|
1561
|
+
"""Recognize the exact HTTP/2 reset emitted by E2B's httpcore control-plane call."""
|
|
1562
|
+
exc_type = type(exc)
|
|
1563
|
+
return (
|
|
1564
|
+
exc_type.__module__ == "httpcore"
|
|
1565
|
+
and exc_type.__name__ == "RemoteProtocolError"
|
|
1566
|
+
and _HTTPCORE_REMOTE_STREAM_RESET.fullmatch(str(exc)) is not None
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def session_entry_files() -> dict[str, str]:
|
|
1571
|
+
"""The in-sandbox runner file(s) a live session uploads, as {filename: content}.
|
|
1572
|
+
|
|
1573
|
+
Public accessor for consumers outside wmo (the platform's live-session driver reads these
|
|
1574
|
+
from the installed wmo package and writes them into the sandbox at start).
|
|
1575
|
+
"""
|
|
1576
|
+
return {name: _read_entry(name) for name in _LIVE_RUNNER_FILES}
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
@overload
|
|
1580
|
+
def start_live_runner(
|
|
1581
|
+
sandbox: SandboxHandle,
|
|
1582
|
+
*,
|
|
1583
|
+
template: str | None = None,
|
|
1584
|
+
workspace: str = LIVE_WORKSPACE,
|
|
1585
|
+
hello_timeout: float = HELLO_TIMEOUT_S,
|
|
1586
|
+
reconnect_while_idle: bool = False,
|
|
1587
|
+
durable_outbox: Literal[False] = False,
|
|
1588
|
+
) -> E2BStdioChannel: ...
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
@overload
|
|
1592
|
+
def start_live_runner(
|
|
1593
|
+
sandbox: SandboxHandle,
|
|
1594
|
+
*,
|
|
1595
|
+
template: str | None = None,
|
|
1596
|
+
workspace: str = LIVE_WORKSPACE,
|
|
1597
|
+
hello_timeout: float = HELLO_TIMEOUT_S,
|
|
1598
|
+
reconnect_while_idle: bool = False,
|
|
1599
|
+
durable_outbox: Literal[True],
|
|
1600
|
+
) -> E2BDurableChannel: ...
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def start_live_runner(
|
|
1604
|
+
sandbox: SandboxHandle,
|
|
1605
|
+
*,
|
|
1606
|
+
template: str | None = None,
|
|
1607
|
+
workspace: str = LIVE_WORKSPACE,
|
|
1608
|
+
hello_timeout: float = HELLO_TIMEOUT_S,
|
|
1609
|
+
reconnect_while_idle: bool = False,
|
|
1610
|
+
durable_outbox: bool = False,
|
|
1611
|
+
) -> E2BStdioChannel | E2BDurableChannel:
|
|
1612
|
+
"""Bootstrap and start `runner_live.ts` on an already-created sandbox; return its channel.
|
|
1613
|
+
|
|
1614
|
+
A live session (unlike a pooled eval episode) is one dedicated sandbox whose lifecycle the
|
|
1615
|
+
caller owns — the caller creates it (setting its own timeout / metadata for reaping) and holds
|
|
1616
|
+
the handle for keepalive, PTY, and reconciliation. This function does only the runner
|
|
1617
|
+
bootstrap: upload the live runner file(s), install node 22 + pi's npm deps unless a template
|
|
1618
|
+
prebakes them, ensure the workspace dir, start the long-lived runner over a stdin-writable
|
|
1619
|
+
background command, and block until its `hello`. By default this returns the established
|
|
1620
|
+
``E2BStdioChannel`` unchanged. ``durable_outbox=True`` instead gives the same ordinary runner a
|
|
1621
|
+
unique sequenced outbox below ``RUNNER_WORKDIR`` and returns an ``E2BDurableChannel``; project
|
|
1622
|
+
files and the agent-facing workspace remain separate from transport state.
|
|
1623
|
+
"""
|
|
1624
|
+
for name in _LIVE_RUNNER_FILES:
|
|
1625
|
+
sandbox.files.write(f"{RUNNER_WORKDIR}/{name}", _read_entry(name))
|
|
1626
|
+
if not (template or os.environ.get(E2B_TEMPLATE_ENV)):
|
|
1627
|
+
sandbox.files.write(f"{RUNNER_WORKDIR}/package.json", _PACKAGE_JSON)
|
|
1628
|
+
sandbox.commands.run(NODE_INSTALL_CMD, timeout=INSTALL_TIMEOUT_S)
|
|
1629
|
+
sandbox.commands.run(
|
|
1630
|
+
f"cd {RUNNER_WORKDIR} && npm install {' '.join(PI_NPM_PACKAGES)}",
|
|
1631
|
+
timeout=INSTALL_TIMEOUT_S,
|
|
1632
|
+
)
|
|
1633
|
+
# `workspace` is a public parameter; quote it so a caller-supplied path can't
|
|
1634
|
+
# inject extra shell commands into the live sandbox.
|
|
1635
|
+
outbox_root: str | None = None
|
|
1636
|
+
stderr_path: str | None = None
|
|
1637
|
+
start_cmd = LIVE_START_CMD
|
|
1638
|
+
if durable_outbox:
|
|
1639
|
+
outbox_root = f"{RUNNER_WORKDIR}/live-outbox-{uuid.uuid4().hex}"
|
|
1640
|
+
stderr_path = f"{outbox_root}/stderr.log"
|
|
1641
|
+
sandbox.commands.run(
|
|
1642
|
+
f"mkdir -p {shlex.quote(workspace)} {shlex.quote(outbox_root + '/frames')}",
|
|
1643
|
+
timeout=30,
|
|
1644
|
+
)
|
|
1645
|
+
start_cmd = f"{LIVE_START_CMD} 2>> {shlex.quote(stderr_path)}"
|
|
1646
|
+
handle = sandbox.commands.run(
|
|
1647
|
+
start_cmd,
|
|
1648
|
+
background=True,
|
|
1649
|
+
envs={"WMO_LIVE_OUTBOX": outbox_root},
|
|
1650
|
+
stdin=True,
|
|
1651
|
+
timeout=0,
|
|
1652
|
+
)
|
|
1653
|
+
channel: E2BStdioChannel | E2BDurableChannel = E2BDurableChannel(
|
|
1654
|
+
sandbox,
|
|
1655
|
+
cast("CommandHandle", handle),
|
|
1656
|
+
outbox_root=outbox_root,
|
|
1657
|
+
stderr_path=stderr_path,
|
|
1658
|
+
)
|
|
1659
|
+
else:
|
|
1660
|
+
sandbox.commands.run(f"mkdir -p {shlex.quote(workspace)}", timeout=30)
|
|
1661
|
+
handle = sandbox.commands.run(LIVE_START_CMD, background=True, stdin=True, timeout=0)
|
|
1662
|
+
channel = E2BStdioChannel(
|
|
1663
|
+
sandbox,
|
|
1664
|
+
cast("CommandHandle", handle),
|
|
1665
|
+
reconnect_while_idle=reconnect_while_idle,
|
|
1666
|
+
)
|
|
1667
|
+
try:
|
|
1668
|
+
deadline = time.monotonic() + hello_timeout
|
|
1669
|
+
while True:
|
|
1670
|
+
remaining = deadline - time.monotonic()
|
|
1671
|
+
if remaining <= 0:
|
|
1672
|
+
raise RuntimeError(_no_hello_live(hello_timeout, channel, start_cmd=start_cmd))
|
|
1673
|
+
try:
|
|
1674
|
+
frame = channel.recv(timeout=remaining)
|
|
1675
|
+
except TimeoutError as exc:
|
|
1676
|
+
raise RuntimeError(
|
|
1677
|
+
_no_hello_live(hello_timeout, channel, start_cmd=start_cmd)
|
|
1678
|
+
) from exc
|
|
1679
|
+
if frame is None:
|
|
1680
|
+
raise RuntimeError(_no_hello_live(hello_timeout, channel, start_cmd=start_cmd))
|
|
1681
|
+
if frame.get("type") == "hello":
|
|
1682
|
+
return channel
|
|
1683
|
+
except Exception:
|
|
1684
|
+
# A failed handshake must not orphan the background node runner + its reader
|
|
1685
|
+
# thread in the caller-owned sandbox; close the channel before propagating.
|
|
1686
|
+
with contextlib.suppress(Exception):
|
|
1687
|
+
channel.close()
|
|
1688
|
+
raise
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def _no_hello_live(
|
|
1692
|
+
timeout: float,
|
|
1693
|
+
channel: E2BStdioChannel | E2BDurableChannel,
|
|
1694
|
+
*,
|
|
1695
|
+
start_cmd: str = LIVE_START_CMD,
|
|
1696
|
+
) -> str:
|
|
1697
|
+
tail = channel.stderr_tail()
|
|
1698
|
+
suffix = f"; recent runner stderr:\n{tail}" if tail else ""
|
|
1699
|
+
return f"live runner sent no hello within {timeout:g}s ({start_cmd!r}){suffix}"
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def _no_hello(timeout: float, channel: E2BStdioChannel) -> str:
|
|
1703
|
+
tail = channel.stderr_tail()
|
|
1704
|
+
suffix = f"; recent runner stderr:\n{tail}" if tail else ""
|
|
1705
|
+
return f"pi runner sent no hello within {timeout:g}s ({START_CMD!r}){suffix}"
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def _read_entry(name: str) -> str:
|
|
1709
|
+
with open(os.path.join(_PI_ENTRY_DIR, name), encoding="utf-8") as fh:
|
|
1710
|
+
return fh.read()
|