failproofai 1.0.3 → 1.0.4-beta.2
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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +5 -5
- package/.next/standalone/.next/prerender-manifest.json +5 -5
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +37 -4
- package/.next/standalone/.next/server/app/audit/page.js +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__0-9ti7z._.js → [externals]__1lh7m5d._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__0u3wrvq._.js → [externals]__1rqkg_y._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +1 -1
- package/.next/standalone/.next/server/chunks/{_1-akg6w._.js → _08w6xzm._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
- package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +23 -0
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0l2hi_d._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +47 -13
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -69
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_rerun-button_tsx_0blihgw._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +54 -21
- package/.next/standalone/.next/static/chunks/010bv1w6j171t.js +1 -0
- package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +1 -0
- package/.next/standalone/.next/static/chunks/{29-iqnp9v_axt.css → 0o-hh5_turzlz.css} +1 -1
- package/.next/standalone/.next/static/chunks/{40nr6ehj4p3cw.css → 0qmsunv04x4na.css} +1 -1
- package/.next/standalone/.next/static/chunks/{0ollg-nnu3t3e.js → 0wz8yftk18ts2.js} +1 -1
- package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +1 -0
- package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
- package/.next/standalone/.next/static/chunks/1pb1oztsbwcss.js +1 -0
- package/.next/standalone/.next/static/chunks/{1-_zkmnm-jwei.js → 2aquitk72k2op.js} +1 -1
- package/.next/standalone/.next/static/chunks/2bi_1y0a_smt7.js +6 -0
- package/.next/standalone/.next/static/chunks/{42yfkhekx440s.js → 2ej3b8gk5ittu.js} +1 -1
- package/.next/standalone/.next/static/chunks/{354-n7p7labpn.js → 2zafgs90r_leq.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2xm5mj1d21b4n.js → 32spub4wqjem-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1a6u7l3giejch.js → 3m4upvybtrexd.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2k9f4tyv04809.css → 3uyhmm01g64k4.css} +1 -0
- package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-3s3c-u_u_l0u9.js} +1 -1
- package/.next/standalone/.opencode/plugins/failproofai.mjs +51 -27
- package/.next/standalone/app/actions/get-leaks.ts +100 -0
- package/.next/standalone/app/audit/_components/audit-dashboard.tsx +112 -33
- package/.next/standalone/app/audit/_components/audit-poster.tsx +29 -10
- package/.next/standalone/app/audit/_components/come-back-better-section.tsx +6 -1
- package/.next/standalone/app/audit/_components/empty-state.tsx +4 -1
- package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +19 -5
- package/.next/standalone/app/audit/_components/leak-section.tsx +179 -0
- package/.next/standalone/app/audit/_components/share-templates.ts +110 -44
- package/.next/standalone/app/audit/audit-styles.css +114 -1
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +26 -3
- package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +31 -8
- package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +1 -0
- package/.next/standalone/fp-cloud-cli/pyproject.toml +9 -2
- package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +54 -0
- package/.next/standalone/fp-cloud-cli/uv.lock +9 -12
- package/.next/standalone/lib/auth/api-server-client.ts +28 -0
- package/.next/standalone/lib/claude-sessions.ts +101 -27
- package/.next/standalone/node_modules/@next/env/package.json +1 -1
- package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +5 -5
- package/.next/standalone/node_modules/next/dist/experimental/testmode/fetch.js +10 -2
- package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
- package/.next/standalone/node_modules/next/dist/lib/typescript/runTypeScriptCli.js +2 -2
- package/.next/standalone/node_modules/next/dist/lib/verify-typescript-setup.js +3 -1
- package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +17 -4
- package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
- package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/escape-path-delimiters.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
- package/.next/standalone/node_modules/next/package.json +11 -11
- package/.next/standalone/package.json +14 -13
- package/.next/standalone/sdk/python/CHANGELOG.md +155 -0
- package/.next/standalone/sdk/python/README.md +8 -0
- package/.next/standalone/sdk/python/examples/evaluator_worker.py +121 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +101 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__main__.py +49 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/_sandbox_runner.py +63 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/authoring.py +404 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +299 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/protocol.py +754 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +936 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/source.py +684 -0
- package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/README.md +28 -0
- package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/contract.json +252 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_authoring.py +124 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_client.py +253 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_example.py +35 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_http_e2e.py +636 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_main.py +47 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_protocol.py +246 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_review_fixes.py +164 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_runtime.py +1103 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_source.py +430 -0
- package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +17 -0
- package/.next/standalone/sdk/python/uv.lock +29 -29
- package/.next/standalone/server.js +1 -1
- package/README.md +13 -58
- package/dist/cli.mjs +5368 -8756
- package/dist/worker.mjs +539 -312
- package/lib/auth/api-server-client.ts +28 -0
- package/lib/claude-sessions.ts +101 -27
- package/package.json +14 -13
- package/pi-extension/index.ts +42 -4
- package/scripts/changelog-open.py +115 -0
- package/src/audit/cli.ts +134 -0
- package/src/audit/desktop-notify.ts +420 -0
- package/src/audit/harm-report.ts +101 -0
- package/src/audit/index.ts +141 -13
- package/src/audit/leak-fingerprint.ts +200 -0
- package/src/audit/leak-notice.ts +161 -0
- package/src/audit/leak-record.ts +232 -0
- package/src/audit/leak-scan.ts +292 -0
- package/src/audit/leak-store.ts +217 -0
- package/src/audit/macos-notifier.ts +310 -0
- package/src/audit/redact-example.ts +266 -14
- package/src/audit/report-harm.ts +12 -1
- package/src/audit/report.ts +6 -3
- package/src/audit/schedule-cli.ts +37 -0
- package/src/audit/scoring.ts +49 -0
- package/src/audit/types.ts +41 -0
- package/src/hooks/builtin-policies.ts +67 -2
- package/src/hooks/configure-wizard.ts +20 -0
- package/src/hooks/fp-config.ts +71 -11
- package/src/hooks/fp-home.ts +27 -0
- package/src/hooks/handler.ts +59 -1
- package/src/hooks/integrations.ts +47 -13
- package/src/hooks/notice.ts +155 -0
- package/src/hooks/uninstall-cli.ts +15 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1b5jzxr._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0j6gd_k._.js +0 -23
- package/.next/standalone/.next/server/chunks/ssr/_0v8eebd._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_1ao30b1.js +0 -3
- package/.next/standalone/.next/static/chunks/03slu0c8_71y7.js +0 -1
- package/.next/standalone/.next/static/chunks/0nm11ysn0153h.js +0 -1
- package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +0 -1
- package/.next/standalone/.next/static/chunks/1r9676mkvihto.js +0 -1
- package/.next/standalone/.next/static/chunks/30pct5s734jua.js +0 -6
- package/.next/standalone/.next/static/chunks/3pibxnbhjza7n.js +0 -69
- package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +0 -1
- package/.next/standalone/.next/static/chunks/3zkg2s2vzxc3d.js +0 -1
- package/.next/standalone/.next/static/chunks/41b5ptaocvi_l.js +0 -1
- /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
"""Async worker state machine for Evaluator v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
import inspect
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
from failproofai_sdk import __version__
|
|
19
|
+
from failproofai_sdk.evaluator.authoring import (
|
|
20
|
+
ConditionResult,
|
|
21
|
+
EvalDefinition,
|
|
22
|
+
EvalResult,
|
|
23
|
+
Evaluator,
|
|
24
|
+
)
|
|
25
|
+
from failproofai_sdk.evaluator.client import EvaluatorAPIError, EvaluatorClient
|
|
26
|
+
from failproofai_sdk.evaluator.protocol import (
|
|
27
|
+
DEFAULT_POLL_INTERVAL_SECONDS,
|
|
28
|
+
MAX_CLAIM_CAPACITY,
|
|
29
|
+
MAX_ERROR_MESSAGE_BYTES,
|
|
30
|
+
MAX_WORKER_ID_BYTES,
|
|
31
|
+
Assignment,
|
|
32
|
+
AssignmentDefinition,
|
|
33
|
+
ClaimRequest,
|
|
34
|
+
EvalSelection,
|
|
35
|
+
ExecutionMode,
|
|
36
|
+
HeartbeatRequest,
|
|
37
|
+
HeartbeatRun,
|
|
38
|
+
PlanRequest,
|
|
39
|
+
RegisterRequest,
|
|
40
|
+
ResultRequest,
|
|
41
|
+
SkippedEval,
|
|
42
|
+
TerminalRunStatus,
|
|
43
|
+
)
|
|
44
|
+
from failproofai_sdk.evaluator.source import (
|
|
45
|
+
EvaluationTimeout,
|
|
46
|
+
UnsafeEvaluatorSource,
|
|
47
|
+
compile_condition,
|
|
48
|
+
compile_evaluator,
|
|
49
|
+
source_checksum,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger("failproofai_sdk.evaluator")
|
|
53
|
+
|
|
54
|
+
# A synchronous evaluator (or condition) that overruns its timeout cannot be
|
|
55
|
+
# cancelled: the executor thread runs the customer function to completion no
|
|
56
|
+
# matter what `asyncio.wait_for` does, because CPython cannot interrupt a running
|
|
57
|
+
# thread. To stop one such orphaned thread from starving live capacity, the eval
|
|
58
|
+
# executor is sized with headroom OVER the concurrency limit — the semaphore, not
|
|
59
|
+
# the thread pool, stays the real bound on how many evaluations run at once. This
|
|
60
|
+
# is a finite cushion, not a cure: a permanently-blocked synchronous evaluator
|
|
61
|
+
# invoked once per session leaks one thread per session, and no fixed pool
|
|
62
|
+
# survives that. `sync_evaluations_orphaned` and a warning make the offending
|
|
63
|
+
# evaluator findable; prefer `async def` evaluators (cooperatively cancellable) or
|
|
64
|
+
# managed PYTHON evaluators (subprocess-isolated, hard-killed) for long or
|
|
65
|
+
# untrusted work.
|
|
66
|
+
_EVAL_EXECUTOR_ORPHAN_HEADROOM = 8
|
|
67
|
+
|
|
68
|
+
# Reserved out of the lease for the plan request (and network jitter) so the
|
|
69
|
+
# pre-plan condition phase always leaves time to submit the plan before the lease
|
|
70
|
+
# expires. See `WorkerRuntime._condition_phase_deadline`.
|
|
71
|
+
_CONDITION_PHASE_SAFETY_MARGIN_SECONDS = 5.0
|
|
72
|
+
|
|
73
|
+
# Fallback wall-clock bound for an evaluation whose definition declares no
|
|
74
|
+
# `timeout_seconds`. A LOCAL (customer-authored) eval is a plain coroutine/thread
|
|
75
|
+
# with no sandbox backstop, so without this an eval that hangs — a wedged
|
|
76
|
+
# `await`, an unbounded judge HTTP call — runs forever, permanently wedging its
|
|
77
|
+
# worker slot and holding the assignment lease. Matches the storage contract's
|
|
78
|
+
# 5-minute per-eval default. (Managed evals are additionally hard-capped inside
|
|
79
|
+
# the fork sandbox, so this is only their outer bound.)
|
|
80
|
+
DEFAULT_EVAL_TIMEOUT_SECONDS = 300.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _utc_now() -> str:
|
|
84
|
+
return (
|
|
85
|
+
datetime.now(timezone.utc)
|
|
86
|
+
.isoformat(timespec="microseconds")
|
|
87
|
+
.replace("+00:00", "Z")
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _deferred_managed_eval(source: str, timeout_seconds: int | None, eval_key: str):
|
|
92
|
+
"""Compile server-authored source lazily, at invocation time.
|
|
93
|
+
|
|
94
|
+
Compilation can reject unsafe or malformed source (``UnsafeEvaluatorSource``).
|
|
95
|
+
Building the definition with this thunk instead of a pre-compiled function
|
|
96
|
+
routes that failure through the same per-run ``try/except`` that turns any
|
|
97
|
+
evaluation error into a bounded ``FAILED`` result — so a poison definition
|
|
98
|
+
dead-letters cleanly as one failed run instead of raising out of assignment
|
|
99
|
+
setup, crashing the task, and forcing the whole assignment to be reclaimed
|
|
100
|
+
and retried until its attempt budget is exhausted.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def evaluate(session: Any) -> Any:
|
|
104
|
+
return compile_evaluator(
|
|
105
|
+
source, timeout_seconds=timeout_seconds, eval_key=eval_key
|
|
106
|
+
)(session)
|
|
107
|
+
|
|
108
|
+
return evaluate
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _positive_int(name: str, default: int) -> int:
|
|
112
|
+
raw = os.environ.get(name)
|
|
113
|
+
if raw is None:
|
|
114
|
+
return default
|
|
115
|
+
try:
|
|
116
|
+
value = int(raw)
|
|
117
|
+
except ValueError as error:
|
|
118
|
+
raise ValueError(f"{name} must be an integer") from error
|
|
119
|
+
if value <= 0:
|
|
120
|
+
raise ValueError(f"{name} must be greater than zero")
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _boolean(name: str, default: bool = False) -> bool:
|
|
125
|
+
raw = os.environ.get(name)
|
|
126
|
+
if raw is None:
|
|
127
|
+
return default
|
|
128
|
+
normalized = raw.strip().lower()
|
|
129
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
130
|
+
return True
|
|
131
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
132
|
+
return False
|
|
133
|
+
raise ValueError(f"{name} must be a boolean")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class WorkerConfig:
|
|
138
|
+
server_url: str
|
|
139
|
+
credential: str
|
|
140
|
+
worker_id: str
|
|
141
|
+
max_concurrency: int = 1
|
|
142
|
+
request_timeout_seconds: int = 30
|
|
143
|
+
drain_timeout_seconds: int = 60
|
|
144
|
+
allow_insecure_http: bool = False
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_env(cls) -> WorkerConfig:
|
|
148
|
+
server_url = os.environ.get("FAILPROOFAI_EVALUATOR_URL", "").strip()
|
|
149
|
+
credential = os.environ.get("FAILPROOFAI_EVALUATOR_TOKEN", "").strip()
|
|
150
|
+
if not server_url:
|
|
151
|
+
raise ValueError("FAILPROOFAI_EVALUATOR_URL is required")
|
|
152
|
+
if not credential:
|
|
153
|
+
raise ValueError("FAILPROOFAI_EVALUATOR_TOKEN is required")
|
|
154
|
+
worker_id = os.environ.get("FAILPROOFAI_EVALUATOR_WORKER_ID", "").strip()
|
|
155
|
+
if not worker_id:
|
|
156
|
+
worker_id = f"{socket.gethostname()}-{os.getpid()}"
|
|
157
|
+
if len(worker_id.encode("utf-8")) > MAX_WORKER_ID_BYTES:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"FAILPROOFAI_EVALUATOR_WORKER_ID exceeds {MAX_WORKER_ID_BYTES} bytes"
|
|
160
|
+
)
|
|
161
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in worker_id):
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"FAILPROOFAI_EVALUATOR_WORKER_ID must not contain control characters"
|
|
164
|
+
)
|
|
165
|
+
config = cls(
|
|
166
|
+
server_url=server_url,
|
|
167
|
+
credential=credential,
|
|
168
|
+
worker_id=worker_id,
|
|
169
|
+
max_concurrency=_positive_int("FAILPROOFAI_EVALUATOR_CONCURRENCY", 1),
|
|
170
|
+
request_timeout_seconds=_positive_int(
|
|
171
|
+
"FAILPROOFAI_EVALUATOR_REQUEST_TIMEOUT_SECONDS", 30
|
|
172
|
+
),
|
|
173
|
+
drain_timeout_seconds=_positive_int(
|
|
174
|
+
"FAILPROOFAI_EVALUATOR_DRAIN_TIMEOUT_SECONDS", 60
|
|
175
|
+
),
|
|
176
|
+
allow_insecure_http=_boolean(
|
|
177
|
+
"FAILPROOFAI_EVALUATOR_ALLOW_INSECURE_HTTP"
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
if config.max_concurrency > MAX_CLAIM_CAPACITY:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"FAILPROOFAI_EVALUATOR_CONCURRENCY exceeds {MAX_CLAIM_CAPACITY}"
|
|
183
|
+
)
|
|
184
|
+
return config
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class WorkerRuntime:
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
evaluator: Evaluator,
|
|
191
|
+
config: WorkerConfig,
|
|
192
|
+
*,
|
|
193
|
+
client: EvaluatorClient | None = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
self.evaluator = evaluator
|
|
196
|
+
self.config = config
|
|
197
|
+
self.client = client or EvaluatorClient(
|
|
198
|
+
base_url=config.server_url,
|
|
199
|
+
credential=config.credential,
|
|
200
|
+
timeout_seconds=config.request_timeout_seconds,
|
|
201
|
+
allow_insecure_http=config.allow_insecure_http,
|
|
202
|
+
)
|
|
203
|
+
self._stopping = asyncio.Event()
|
|
204
|
+
self._active: set[asyncio.Task[None]] = set()
|
|
205
|
+
self._heartbeat_interval = 30
|
|
206
|
+
self._poll_interval = DEFAULT_POLL_INTERVAL_SECONDS
|
|
207
|
+
self._claim_limit = config.max_concurrency
|
|
208
|
+
self._lease_duration = 120
|
|
209
|
+
self._disabled_definitions: set[str] = set()
|
|
210
|
+
self._eval_semaphore = asyncio.Semaphore(config.max_concurrency)
|
|
211
|
+
# Headroom over the semaphore so a timed-out-but-still-running synchronous
|
|
212
|
+
# evaluator (an unkillable orphaned thread) does not immediately starve
|
|
213
|
+
# live capacity — the semaphore remains the true concurrency bound. See
|
|
214
|
+
# `_EVAL_EXECUTOR_ORPHAN_HEADROOM`.
|
|
215
|
+
self._eval_executor = concurrent.futures.ThreadPoolExecutor(
|
|
216
|
+
max_workers=config.max_concurrency + _EVAL_EXECUTOR_ORPHAN_HEADROOM,
|
|
217
|
+
thread_name_prefix="failproof-eval",
|
|
218
|
+
)
|
|
219
|
+
self._registered = False
|
|
220
|
+
self._last_server_contact: float | None = None
|
|
221
|
+
self._metric_lock = threading.Lock()
|
|
222
|
+
self._metrics: dict[str, int] = {}
|
|
223
|
+
|
|
224
|
+
async def register(self) -> None:
|
|
225
|
+
try:
|
|
226
|
+
response = await self._call_client(
|
|
227
|
+
self.client.register,
|
|
228
|
+
RegisterRequest(
|
|
229
|
+
worker_id=self.config.worker_id,
|
|
230
|
+
sdk_version=__version__,
|
|
231
|
+
catalog_revision=self.evaluator.catalog_revision,
|
|
232
|
+
max_concurrency=self.config.max_concurrency,
|
|
233
|
+
definitions=self.evaluator.catalog(),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
except Exception:
|
|
237
|
+
self._increment("registration_failure")
|
|
238
|
+
raise
|
|
239
|
+
self._heartbeat_interval = response.heartbeat_interval_seconds
|
|
240
|
+
self._poll_interval = response.poll_interval_seconds
|
|
241
|
+
self._lease_duration = response.lease_duration_seconds
|
|
242
|
+
self._claim_limit = min(self.config.max_concurrency, response.claim_limit)
|
|
243
|
+
if (
|
|
244
|
+
self._heartbeat_interval <= 0
|
|
245
|
+
or self._poll_interval <= 0
|
|
246
|
+
or self._lease_duration <= self._heartbeat_interval
|
|
247
|
+
or self._claim_limit <= 0
|
|
248
|
+
):
|
|
249
|
+
self._increment("registration_failure")
|
|
250
|
+
raise RuntimeError(
|
|
251
|
+
"server returned invalid evaluator timing or claim limits"
|
|
252
|
+
)
|
|
253
|
+
self._disabled_definitions = set(response.disabled_definitions)
|
|
254
|
+
self._registered = True
|
|
255
|
+
self._increment("registration_success")
|
|
256
|
+
|
|
257
|
+
async def run_forever(self) -> None:
|
|
258
|
+
await self.register()
|
|
259
|
+
retry_delay = 1.0
|
|
260
|
+
try:
|
|
261
|
+
while not self._stopping.is_set():
|
|
262
|
+
self._reap_finished()
|
|
263
|
+
capacity = self._claim_limit - len(self._active)
|
|
264
|
+
if capacity <= 0:
|
|
265
|
+
await self._wait_for_progress()
|
|
266
|
+
continue
|
|
267
|
+
try:
|
|
268
|
+
response = await self._call_client(
|
|
269
|
+
self.client.claim,
|
|
270
|
+
ClaimRequest(
|
|
271
|
+
worker_id=self.config.worker_id,
|
|
272
|
+
catalog_revision=self.evaluator.catalog_revision,
|
|
273
|
+
capacity=capacity,
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
except EvaluatorAPIError as error:
|
|
277
|
+
self._increment("claim_failures")
|
|
278
|
+
logger.warning(
|
|
279
|
+
"evaluator claim failed",
|
|
280
|
+
extra={"code": error.code, "retryable": error.retryable},
|
|
281
|
+
)
|
|
282
|
+
if not error.retryable:
|
|
283
|
+
raise
|
|
284
|
+
delay = (
|
|
285
|
+
float(self._lease_duration)
|
|
286
|
+
if error.status is None
|
|
287
|
+
else retry_delay
|
|
288
|
+
)
|
|
289
|
+
await self._wait_or_stop(delay)
|
|
290
|
+
retry_delay = min(retry_delay * 2.0, 30.0)
|
|
291
|
+
continue
|
|
292
|
+
retry_delay = 1.0
|
|
293
|
+
assignments = self._validated_assignments(
|
|
294
|
+
response.assignments, capacity
|
|
295
|
+
)
|
|
296
|
+
for assignment in assignments:
|
|
297
|
+
task = asyncio.create_task(self.process_assignment(assignment))
|
|
298
|
+
self._active.add(task)
|
|
299
|
+
self._increment("assignments_claimed", len(assignments))
|
|
300
|
+
if not assignments:
|
|
301
|
+
# Normal short poll: the server returns immediately, so when
|
|
302
|
+
# nothing is queued we wait the advertised interval before
|
|
303
|
+
# polling again instead of hot-looping. When work IS returned
|
|
304
|
+
# we loop straight back to drain any backlog up to capacity.
|
|
305
|
+
await self._wait_or_stop(float(self._poll_interval))
|
|
306
|
+
finally:
|
|
307
|
+
await self.drain()
|
|
308
|
+
self._eval_executor.shutdown(wait=False, cancel_futures=True)
|
|
309
|
+
|
|
310
|
+
async def run_once(self) -> int:
|
|
311
|
+
"""Claim once and finish the returned assignments; useful for jobs/tests."""
|
|
312
|
+
response = await self._call_client(
|
|
313
|
+
self.client.claim,
|
|
314
|
+
ClaimRequest(
|
|
315
|
+
worker_id=self.config.worker_id,
|
|
316
|
+
catalog_revision=self.evaluator.catalog_revision,
|
|
317
|
+
capacity=self._claim_limit,
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
assignments = self._validated_assignments(
|
|
321
|
+
response.assignments, self._claim_limit
|
|
322
|
+
)
|
|
323
|
+
self._increment("assignments_claimed", len(assignments))
|
|
324
|
+
await asyncio.gather(*(self.process_assignment(item) for item in assignments))
|
|
325
|
+
return len(assignments)
|
|
326
|
+
|
|
327
|
+
def stop(self) -> None:
|
|
328
|
+
self._stopping.set()
|
|
329
|
+
|
|
330
|
+
async def drain(self) -> None:
|
|
331
|
+
self._reap_finished()
|
|
332
|
+
if not self._active:
|
|
333
|
+
return
|
|
334
|
+
done, pending = await asyncio.wait(
|
|
335
|
+
self._active, timeout=self.config.drain_timeout_seconds
|
|
336
|
+
)
|
|
337
|
+
for task in done:
|
|
338
|
+
self._consume_task(task)
|
|
339
|
+
for task in pending:
|
|
340
|
+
task.cancel()
|
|
341
|
+
if pending:
|
|
342
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
343
|
+
self._active.clear()
|
|
344
|
+
|
|
345
|
+
async def process_assignment(self, assignment: Assignment) -> None:
|
|
346
|
+
try:
|
|
347
|
+
session = await self._call_client(
|
|
348
|
+
self.client.transcript,
|
|
349
|
+
assignment,
|
|
350
|
+
worker_id=self.config.worker_id,
|
|
351
|
+
)
|
|
352
|
+
except EvaluatorAPIError as error:
|
|
353
|
+
if error.code == "transcript_too_large":
|
|
354
|
+
# The session transcript exceeds the hard ceiling. No runs are
|
|
355
|
+
# planned yet, so there is nothing to submit a per-run result for,
|
|
356
|
+
# and the error is non-retryable — re-raising would only wedge the
|
|
357
|
+
# poll loop and burn the assignment's whole retry budget against a
|
|
358
|
+
# transcript that can never shrink. Log and return; the server
|
|
359
|
+
# terminalizes the assignment as `too_large`.
|
|
360
|
+
logger.warning(
|
|
361
|
+
"assignment %s transcript is too large to evaluate; skipping",
|
|
362
|
+
assignment.assignment_id,
|
|
363
|
+
)
|
|
364
|
+
self._increment("transcripts_too_large")
|
|
365
|
+
return
|
|
366
|
+
raise
|
|
367
|
+
if session.session_revision_id != assignment.session_revision_id:
|
|
368
|
+
raise RuntimeError("transcript session revision does not match assignment")
|
|
369
|
+
|
|
370
|
+
descriptors = await self._assignment_definitions(assignment)
|
|
371
|
+
# Every descriptor the assignment carries, keyed for reconstruction: on an
|
|
372
|
+
# idempotent replay the server re-serves the first attempt's run set, which
|
|
373
|
+
# may include a run this attempt's re-derived plan would have skipped.
|
|
374
|
+
descriptor_by_key = {
|
|
375
|
+
(item.eval_key, item.eval_version): item for item in descriptors
|
|
376
|
+
}
|
|
377
|
+
selected: list[tuple[AssignmentDefinition, EvalDefinition | None]] = []
|
|
378
|
+
skipped: list[SkippedEval] = []
|
|
379
|
+
local_definitions = {
|
|
380
|
+
(item.eval_key, item.eval_version): item
|
|
381
|
+
for item in self.evaluator.definitions
|
|
382
|
+
}
|
|
383
|
+
# The assignment lease is fixed at claim time and cannot be renewed until
|
|
384
|
+
# the plan is submitted (the server only extends a lease for a *planned*
|
|
385
|
+
# assignment with running runs). A slow condition phase can therefore burn
|
|
386
|
+
# the whole lease and get the plan fenced as lease_lost, so every
|
|
387
|
+
# condition is bounded by the lease it must leave time to plan within.
|
|
388
|
+
condition_deadline = self._condition_phase_deadline(assignment)
|
|
389
|
+
for descriptor in descriptors:
|
|
390
|
+
local = local_definitions.get(
|
|
391
|
+
(descriptor.eval_key, descriptor.eval_version)
|
|
392
|
+
)
|
|
393
|
+
if descriptor.execution_mode is ExecutionMode.LOCAL and local is None:
|
|
394
|
+
raise RuntimeError("server requested a definition absent from this worker")
|
|
395
|
+
if descriptor.eval_key in self._disabled_definitions:
|
|
396
|
+
skipped.append(self._skipped_descriptor(descriptor, "disabled_by_server"))
|
|
397
|
+
self._increment("conditions_skipped")
|
|
398
|
+
continue
|
|
399
|
+
try:
|
|
400
|
+
# Whose condition decides applicability follows the execution mode,
|
|
401
|
+
# mirroring the evaluator branch below (`run.execution_mode`): a LOCAL
|
|
402
|
+
# definition's condition is client-authored (`local.condition`); a
|
|
403
|
+
# PYTHON (managed) definition's is server-authored and MUST govern even
|
|
404
|
+
# when the worker also registered the same key/version locally. Keying
|
|
405
|
+
# `local` on `(eval_key, eval_version)` alone means a managed def can
|
|
406
|
+
# collide with a local one; selecting `local.condition` there would let
|
|
407
|
+
# a matching local condition override the server's managed rule and run
|
|
408
|
+
# the managed evaluator against the operator's intent (COR-001).
|
|
409
|
+
# Compile INSIDE the try: a managed condition the sandbox rejects
|
|
410
|
+
# (unsafe/malformed source) must dead-letter as `condition_error`,
|
|
411
|
+
# not raise out of the plan loop and strand the whole assignment
|
|
412
|
+
# until its retry budget is exhausted.
|
|
413
|
+
managed_condition_source: str | None = None
|
|
414
|
+
if descriptor.execution_mode is ExecutionMode.LOCAL:
|
|
415
|
+
condition_function = local.condition if local is not None else None
|
|
416
|
+
elif descriptor.condition_source:
|
|
417
|
+
# Compiled below, once the lease budget is known, so the
|
|
418
|
+
# sandbox subprocess is bounded by whatever lease remains.
|
|
419
|
+
managed_condition_source = descriptor.condition_source
|
|
420
|
+
condition_function = None
|
|
421
|
+
else:
|
|
422
|
+
condition_function = None
|
|
423
|
+
if condition_function is None and managed_condition_source is None:
|
|
424
|
+
# No condition to run — applicable by default, no lease spent.
|
|
425
|
+
selected.append((descriptor, local))
|
|
426
|
+
continue
|
|
427
|
+
budget = self._condition_budget(
|
|
428
|
+
condition_deadline, descriptor.timeout_seconds
|
|
429
|
+
)
|
|
430
|
+
if budget <= 0.0:
|
|
431
|
+
# Not enough lease left to evaluate this condition and still
|
|
432
|
+
# submit the plan in time; skip it (and, as the loop proceeds,
|
|
433
|
+
# every later condition) rather than do work the server will
|
|
434
|
+
# fence as lease_lost and reclaim in a loop.
|
|
435
|
+
skipped.append(
|
|
436
|
+
self._skipped_descriptor(descriptor, "lease_exhausted")
|
|
437
|
+
)
|
|
438
|
+
self._increment("conditions_skipped")
|
|
439
|
+
self._increment("conditions_lease_exhausted")
|
|
440
|
+
continue
|
|
441
|
+
if managed_condition_source is not None:
|
|
442
|
+
condition_function = compile_condition(
|
|
443
|
+
managed_condition_source,
|
|
444
|
+
timeout_seconds=budget,
|
|
445
|
+
)
|
|
446
|
+
condition = await asyncio.wait_for(
|
|
447
|
+
self._invoke(condition_function, session), timeout=budget
|
|
448
|
+
)
|
|
449
|
+
if isinstance(condition, ConditionResult):
|
|
450
|
+
applicable = condition.applicable
|
|
451
|
+
reason_code = condition.reason_code
|
|
452
|
+
elif isinstance(condition, bool):
|
|
453
|
+
applicable = condition
|
|
454
|
+
reason_code = "condition_false"
|
|
455
|
+
else:
|
|
456
|
+
raise TypeError("condition must return bool or ConditionResult")
|
|
457
|
+
except Exception as error: # noqa: BLE001 - isolates customer condition code
|
|
458
|
+
logger.warning(
|
|
459
|
+
"evaluator condition failed",
|
|
460
|
+
extra={
|
|
461
|
+
"assignment_id": assignment.assignment_id,
|
|
462
|
+
"error_type": type(error).__name__,
|
|
463
|
+
},
|
|
464
|
+
)
|
|
465
|
+
skipped.append(self._skipped_descriptor(descriptor, "condition_error"))
|
|
466
|
+
self._increment("conditions_skipped")
|
|
467
|
+
continue
|
|
468
|
+
if applicable:
|
|
469
|
+
selected.append((descriptor, local))
|
|
470
|
+
self._increment("conditions_selected")
|
|
471
|
+
else:
|
|
472
|
+
skipped.append(self._skipped_descriptor(descriptor, reason_code))
|
|
473
|
+
self._increment("conditions_skipped")
|
|
474
|
+
|
|
475
|
+
plan = await self._call_client(
|
|
476
|
+
self.client.plan,
|
|
477
|
+
assignment.assignment_id,
|
|
478
|
+
PlanRequest(
|
|
479
|
+
worker_id=self.config.worker_id,
|
|
480
|
+
lease_generation=assignment.lease_generation,
|
|
481
|
+
selected=tuple(
|
|
482
|
+
EvalSelection(item.eval_key, item.eval_version)
|
|
483
|
+
for item, _local in selected
|
|
484
|
+
),
|
|
485
|
+
skipped=tuple(skipped),
|
|
486
|
+
),
|
|
487
|
+
)
|
|
488
|
+
if plan.assignment_id != assignment.assignment_id:
|
|
489
|
+
raise RuntimeError("server returned a plan for a different assignment")
|
|
490
|
+
# On an idempotent replay the server's status is authoritative: this
|
|
491
|
+
# attempt may have selected a different set than the first, so a mismatch
|
|
492
|
+
# against our own `selected` is expected, not an error.
|
|
493
|
+
if not plan.idempotent_replay:
|
|
494
|
+
expected_status = "planned" if selected else "skipped"
|
|
495
|
+
if plan.assignment_status != expected_status:
|
|
496
|
+
raise RuntimeError("server returned an inconsistent assignment status")
|
|
497
|
+
|
|
498
|
+
definitions = {
|
|
499
|
+
(item.eval_key, item.eval_version): (item, local)
|
|
500
|
+
for item, local in selected
|
|
501
|
+
}
|
|
502
|
+
run_definitions: list[tuple[str, EvalDefinition]] = []
|
|
503
|
+
run_ids: set[str] = set()
|
|
504
|
+
for run in plan.runs:
|
|
505
|
+
if run.evaluation_run_id in run_ids:
|
|
506
|
+
raise RuntimeError("server returned a duplicate evaluation run id")
|
|
507
|
+
run_ids.add(run.evaluation_run_id)
|
|
508
|
+
run_key = (run.eval_key, run.eval_version)
|
|
509
|
+
selected_definition = definitions.pop(run_key, None)
|
|
510
|
+
if selected_definition is None:
|
|
511
|
+
# On an idempotent replay the server's run set is AUTHORITATIVE —
|
|
512
|
+
# it re-serves the first attempt's runs even for a definition this
|
|
513
|
+
# attempt's condition phase would have skipped. Reconstruct the
|
|
514
|
+
# definition from the assignment's descriptors rather than raising
|
|
515
|
+
# and dead-lettering an assignment that could otherwise never
|
|
516
|
+
# converge (the divergence-abort bug).
|
|
517
|
+
if plan.idempotent_replay:
|
|
518
|
+
replay_descriptor = descriptor_by_key.get(run_key)
|
|
519
|
+
if replay_descriptor is not None:
|
|
520
|
+
selected_definition = (
|
|
521
|
+
replay_descriptor,
|
|
522
|
+
local_definitions.get(run_key),
|
|
523
|
+
)
|
|
524
|
+
if selected_definition is None:
|
|
525
|
+
raise RuntimeError("server returned an unrequested evaluation run")
|
|
526
|
+
descriptor, local = selected_definition
|
|
527
|
+
if run.execution_mode is not descriptor.execution_mode:
|
|
528
|
+
raise RuntimeError("server changed the evaluation execution mode")
|
|
529
|
+
if run.execution_mode is ExecutionMode.LOCAL:
|
|
530
|
+
if local is None:
|
|
531
|
+
raise RuntimeError("local evaluation definition is unavailable")
|
|
532
|
+
definition = local
|
|
533
|
+
else:
|
|
534
|
+
if not run.evaluator_source or not run.source_checksum:
|
|
535
|
+
raise RuntimeError("server omitted managed evaluation source")
|
|
536
|
+
expected = source_checksum(
|
|
537
|
+
descriptor.condition_source, run.evaluator_source
|
|
538
|
+
)
|
|
539
|
+
if expected != run.source_checksum or (
|
|
540
|
+
descriptor.source_checksum
|
|
541
|
+
and descriptor.source_checksum != run.source_checksum
|
|
542
|
+
):
|
|
543
|
+
raise RuntimeError("managed evaluation source checksum mismatch")
|
|
544
|
+
definition = EvalDefinition(
|
|
545
|
+
eval_key=descriptor.eval_key,
|
|
546
|
+
display_name=descriptor.display_name,
|
|
547
|
+
eval_version=descriptor.eval_version,
|
|
548
|
+
result_kind=descriptor.result_kind,
|
|
549
|
+
labels=descriptor.labels,
|
|
550
|
+
function=_deferred_managed_eval(
|
|
551
|
+
run.evaluator_source,
|
|
552
|
+
run.timeout_seconds or descriptor.timeout_seconds,
|
|
553
|
+
descriptor.eval_key,
|
|
554
|
+
),
|
|
555
|
+
condition=None,
|
|
556
|
+
on_cancel=None,
|
|
557
|
+
timeout_seconds=run.timeout_seconds or descriptor.timeout_seconds,
|
|
558
|
+
)
|
|
559
|
+
run_definitions.append((run.evaluation_run_id, definition))
|
|
560
|
+
if definitions and not plan.idempotent_replay:
|
|
561
|
+
raise RuntimeError("server omitted a selected evaluation run")
|
|
562
|
+
|
|
563
|
+
tasks = {
|
|
564
|
+
run_id: asyncio.create_task(
|
|
565
|
+
self._execute_run(assignment, run_id, definition, session)
|
|
566
|
+
)
|
|
567
|
+
for run_id, definition in run_definitions
|
|
568
|
+
}
|
|
569
|
+
heartbeat = asyncio.create_task(self._heartbeat(assignment, tasks))
|
|
570
|
+
try:
|
|
571
|
+
outcomes = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
|
572
|
+
for outcome in outcomes:
|
|
573
|
+
if isinstance(outcome, BaseException):
|
|
574
|
+
raise outcome
|
|
575
|
+
finally:
|
|
576
|
+
heartbeat.cancel()
|
|
577
|
+
await asyncio.gather(heartbeat, return_exceptions=True)
|
|
578
|
+
|
|
579
|
+
async def _execute_run(
|
|
580
|
+
self,
|
|
581
|
+
assignment: Assignment,
|
|
582
|
+
run_id: str,
|
|
583
|
+
definition: EvalDefinition,
|
|
584
|
+
session: Any,
|
|
585
|
+
) -> None:
|
|
586
|
+
async with self._eval_semaphore:
|
|
587
|
+
await self._execute_run_in_slot(assignment, run_id, definition, session)
|
|
588
|
+
|
|
589
|
+
async def _execute_run_in_slot(
|
|
590
|
+
self,
|
|
591
|
+
assignment: Assignment,
|
|
592
|
+
run_id: str,
|
|
593
|
+
definition: EvalDefinition,
|
|
594
|
+
session: Any,
|
|
595
|
+
) -> None:
|
|
596
|
+
started_at = _utc_now()
|
|
597
|
+
started = time.monotonic()
|
|
598
|
+
# A synchronous evaluator runs in the executor thread; if it overruns the
|
|
599
|
+
# wall-clock timeout below, the thread cannot be cancelled and is orphaned.
|
|
600
|
+
sync_function = not inspect.iscoroutinefunction(definition.function)
|
|
601
|
+
try:
|
|
602
|
+
invocation = self._invoke(definition.function, session)
|
|
603
|
+
# Always bound the evaluation. A definition with no declared
|
|
604
|
+
# timeout_seconds falls back to DEFAULT_EVAL_TIMEOUT_SECONDS rather
|
|
605
|
+
# than awaiting unbounded — an unbounded local eval that hangs would
|
|
606
|
+
# wedge its worker slot and hold the lease forever.
|
|
607
|
+
eval_timeout = (
|
|
608
|
+
definition.timeout_seconds
|
|
609
|
+
if definition.timeout_seconds is not None
|
|
610
|
+
else DEFAULT_EVAL_TIMEOUT_SECONDS
|
|
611
|
+
)
|
|
612
|
+
result = await asyncio.wait_for(invocation, timeout=eval_timeout)
|
|
613
|
+
if not isinstance(result, EvalResult):
|
|
614
|
+
raise TypeError("evaluation must return EvalResult")
|
|
615
|
+
items = result.result_items(definition.eval_key)
|
|
616
|
+
if not any(
|
|
617
|
+
item.result_key == definition.eval_key
|
|
618
|
+
and item.result_kind == definition.result_kind
|
|
619
|
+
for item in items
|
|
620
|
+
):
|
|
621
|
+
raise ValueError(
|
|
622
|
+
"evaluation result does not contain its declared primary result"
|
|
623
|
+
)
|
|
624
|
+
status = TerminalRunStatus.SUCCEEDED
|
|
625
|
+
summary = result.summary
|
|
626
|
+
error_code = None
|
|
627
|
+
error_message = None
|
|
628
|
+
except (asyncio.TimeoutError, EvaluationTimeout) as timeout_error:
|
|
629
|
+
# asyncio.TimeoutError: the awaiter hit the wall-clock. EvaluationTimeout:
|
|
630
|
+
# the forked managed sandbox was killed by its CPU/memory/time budget —
|
|
631
|
+
# the real, thread-uncancellable case. Both are a timed-out run.
|
|
632
|
+
await self._cancel_hook(definition, session)
|
|
633
|
+
if isinstance(timeout_error, asyncio.TimeoutError) and sync_function:
|
|
634
|
+
# The awaiter gave up while a SYNCHRONOUS evaluator was still
|
|
635
|
+
# running in the executor. CPython cannot interrupt that thread,
|
|
636
|
+
# so it is now orphaned — it runs to completion (or forever)
|
|
637
|
+
# holding a worker thread. Count it and name the evaluator so a
|
|
638
|
+
# hung one is findable; the executor's headroom keeps this one
|
|
639
|
+
# orphan from immediately starving live capacity.
|
|
640
|
+
self._increment("sync_evaluations_orphaned")
|
|
641
|
+
logger.warning(
|
|
642
|
+
"synchronous evaluation exceeded its timeout and cannot be "
|
|
643
|
+
"cancelled; its worker thread is orphaned until it returns",
|
|
644
|
+
extra={
|
|
645
|
+
"assignment_id": assignment.assignment_id,
|
|
646
|
+
"eval_key": definition.eval_key,
|
|
647
|
+
},
|
|
648
|
+
)
|
|
649
|
+
items = ()
|
|
650
|
+
status = TerminalRunStatus.TIMED_OUT
|
|
651
|
+
summary = None
|
|
652
|
+
error_code = "eval_timeout"
|
|
653
|
+
error_message = "evaluation exceeded its configured timeout"
|
|
654
|
+
except asyncio.CancelledError:
|
|
655
|
+
await self._cancel_hook(definition, session)
|
|
656
|
+
raise
|
|
657
|
+
except UnsafeEvaluatorSource as error:
|
|
658
|
+
# Surface the REASON for a rejected server-authored definition.
|
|
659
|
+
#
|
|
660
|
+
# This is deliberately narrower than the generic handler below.
|
|
661
|
+
# UnsafeEvaluatorSource is raised by our own validator before any
|
|
662
|
+
# customer source executes, and its message is SDK-authored text
|
|
663
|
+
# about the source's shape ("evaluator_source must be one
|
|
664
|
+
# expression", "contains disallowed syntax: Assign") — it embeds no
|
|
665
|
+
# transcript content, so it is safe to send back over the wire.
|
|
666
|
+
#
|
|
667
|
+
# Without this the author saw only "evaluation raised
|
|
668
|
+
# UnsafeEvaluatorSource" on every session, with no way to learn what
|
|
669
|
+
# was wrong: the server accepts any source that passes its size and
|
|
670
|
+
# key checks, so a definition that can never run is published
|
|
671
|
+
# successfully and then fails silently and permanently.
|
|
672
|
+
items = ()
|
|
673
|
+
status = TerminalRunStatus.FAILED
|
|
674
|
+
summary = None
|
|
675
|
+
error_code = "eval_error"
|
|
676
|
+
detail = str(error).strip()
|
|
677
|
+
error_message = (
|
|
678
|
+
f"evaluator source rejected: {detail}"
|
|
679
|
+
if detail
|
|
680
|
+
else "evaluator source rejected by the sandbox validator"
|
|
681
|
+
)
|
|
682
|
+
encoded = error_message.encode("utf-8")
|
|
683
|
+
if len(encoded) > MAX_ERROR_MESSAGE_BYTES:
|
|
684
|
+
error_message = encoded[:MAX_ERROR_MESSAGE_BYTES].decode(
|
|
685
|
+
"utf-8", "ignore"
|
|
686
|
+
)
|
|
687
|
+
except Exception as error: # noqa: BLE001 - converts customer eval failures
|
|
688
|
+
# Type name ONLY on the wire. A customer eval's exception text can
|
|
689
|
+
# quote the transcript it was reading, and this field is persisted
|
|
690
|
+
# and shown in the dashboard, so the message itself is not repeated
|
|
691
|
+
# there. But log the FULL traceback LOCALLY: this runs on the
|
|
692
|
+
# customer's own pod over their own data, and without it an author
|
|
693
|
+
# whose eval raises sees only "evaluation raised HTTPError" in the
|
|
694
|
+
# dashboard and nothing at all in their pod logs — no way to debug
|
|
695
|
+
# their own eval.
|
|
696
|
+
logger.warning(
|
|
697
|
+
"evaluation %r raised %s; reported to the server as a failed run",
|
|
698
|
+
definition.eval_key,
|
|
699
|
+
type(error).__name__,
|
|
700
|
+
exc_info=True,
|
|
701
|
+
)
|
|
702
|
+
items = ()
|
|
703
|
+
status = TerminalRunStatus.FAILED
|
|
704
|
+
summary = None
|
|
705
|
+
error_code = "eval_error"
|
|
706
|
+
error_message = f"evaluation raised {type(error).__name__}"
|
|
707
|
+
|
|
708
|
+
request = ResultRequest(
|
|
709
|
+
submission_id=str(uuid4()),
|
|
710
|
+
worker_id=self.config.worker_id,
|
|
711
|
+
lease_generation=assignment.lease_generation,
|
|
712
|
+
status=status,
|
|
713
|
+
started_at=started_at,
|
|
714
|
+
finished_at=_utc_now(),
|
|
715
|
+
duration_ms=max(0, round((time.monotonic() - started) * 1_000)),
|
|
716
|
+
summary=summary,
|
|
717
|
+
results=items,
|
|
718
|
+
error_code=error_code,
|
|
719
|
+
error_message=error_message,
|
|
720
|
+
)
|
|
721
|
+
await self._call_client(self.client.submit_result, run_id, request)
|
|
722
|
+
self._increment(f"runs_{status.value}")
|
|
723
|
+
|
|
724
|
+
async def _cancel_hook(self, definition: EvalDefinition, session: Any) -> None:
|
|
725
|
+
if definition.on_cancel is None:
|
|
726
|
+
return
|
|
727
|
+
try:
|
|
728
|
+
await self._invoke(definition.on_cancel, session)
|
|
729
|
+
except Exception as error: # noqa: BLE001 - cancellation hooks are customer code
|
|
730
|
+
logger.warning(
|
|
731
|
+
"evaluator cancellation hook failed",
|
|
732
|
+
extra={"error_type": type(error).__name__},
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
async def _heartbeat(
|
|
736
|
+
self,
|
|
737
|
+
assignment: Assignment,
|
|
738
|
+
tasks: dict[str, asyncio.Task[None]],
|
|
739
|
+
) -> None:
|
|
740
|
+
# Beat IMMEDIATELY, before the first sleep. The pre-plan condition phase
|
|
741
|
+
# may have consumed most of the claim-time lease, and the server only
|
|
742
|
+
# renews a planned assignment's lease on heartbeat — so sleeping a full
|
|
743
|
+
# interval here can let the lease expire before the first renewal, after
|
|
744
|
+
# the runs have already started, cancelling every one of them. The first
|
|
745
|
+
# beat renews the lease the moment the runs are live.
|
|
746
|
+
first = True
|
|
747
|
+
while True:
|
|
748
|
+
if not first:
|
|
749
|
+
await asyncio.sleep(self._heartbeat_interval)
|
|
750
|
+
first = False
|
|
751
|
+
active = tuple(
|
|
752
|
+
HeartbeatRun(evaluation_run_id=run_id, state="running")
|
|
753
|
+
for run_id, task in tasks.items()
|
|
754
|
+
if not task.done()
|
|
755
|
+
)
|
|
756
|
+
if not active:
|
|
757
|
+
return
|
|
758
|
+
try:
|
|
759
|
+
response = await self._call_client(
|
|
760
|
+
self.client.heartbeat,
|
|
761
|
+
HeartbeatRequest(
|
|
762
|
+
worker_id=self.config.worker_id,
|
|
763
|
+
lease_generation=assignment.lease_generation,
|
|
764
|
+
runs=active,
|
|
765
|
+
),
|
|
766
|
+
)
|
|
767
|
+
accepted = set(response.accepted_run_ids)
|
|
768
|
+
for run_id, task in tasks.items():
|
|
769
|
+
if not task.done() and run_id not in accepted:
|
|
770
|
+
task.cancel()
|
|
771
|
+
except EvaluatorAPIError as error:
|
|
772
|
+
if error.code == "lease_lost":
|
|
773
|
+
self._increment("leases_lost")
|
|
774
|
+
for task in tasks.values():
|
|
775
|
+
task.cancel()
|
|
776
|
+
return
|
|
777
|
+
logger.warning(
|
|
778
|
+
"evaluator heartbeat failed",
|
|
779
|
+
extra={
|
|
780
|
+
"assignment_id": assignment.assignment_id,
|
|
781
|
+
"code": error.code,
|
|
782
|
+
},
|
|
783
|
+
)
|
|
784
|
+
self._increment("heartbeat_failures")
|
|
785
|
+
except Exception as error: # noqa: BLE001 - keep lease renewal alive
|
|
786
|
+
logger.warning(
|
|
787
|
+
"evaluator heartbeat error",
|
|
788
|
+
extra={
|
|
789
|
+
"assignment_id": assignment.assignment_id,
|
|
790
|
+
"error_type": type(error).__name__,
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
self._increment("heartbeat_failures")
|
|
794
|
+
|
|
795
|
+
def _condition_phase_deadline(self, assignment: Assignment) -> float:
|
|
796
|
+
"""Monotonic-clock reading by which the pre-plan condition phase must end.
|
|
797
|
+
|
|
798
|
+
The real `lease_expires_at` is used when it is in the future (production);
|
|
799
|
+
a past or unparseable value (clock skew, or a replayed transcript in a
|
|
800
|
+
test) falls back to the negotiated lease duration measured from now, so
|
|
801
|
+
the bound never fires spuriously on a stale deadline.
|
|
802
|
+
"""
|
|
803
|
+
remaining = float(self._lease_duration)
|
|
804
|
+
try:
|
|
805
|
+
expires = datetime.fromisoformat(
|
|
806
|
+
assignment.lease_expires_at.replace("Z", "+00:00")
|
|
807
|
+
)
|
|
808
|
+
parsed = (expires - datetime.now(timezone.utc)).total_seconds()
|
|
809
|
+
if parsed > 0:
|
|
810
|
+
remaining = parsed
|
|
811
|
+
except (ValueError, AttributeError):
|
|
812
|
+
pass
|
|
813
|
+
return time.monotonic() + remaining
|
|
814
|
+
|
|
815
|
+
def _condition_budget(
|
|
816
|
+
self, deadline: float, timeout_seconds: int | None
|
|
817
|
+
) -> float:
|
|
818
|
+
"""Seconds a single condition may run: the lease left before the
|
|
819
|
+
plan-submission margin, capped by the definition's own timeout."""
|
|
820
|
+
remaining = (
|
|
821
|
+
deadline - time.monotonic() - _CONDITION_PHASE_SAFETY_MARGIN_SECONDS
|
|
822
|
+
)
|
|
823
|
+
if timeout_seconds is not None:
|
|
824
|
+
remaining = min(remaining, float(timeout_seconds))
|
|
825
|
+
return remaining
|
|
826
|
+
|
|
827
|
+
async def _invoke(self, function, session):
|
|
828
|
+
if inspect.iscoroutinefunction(function):
|
|
829
|
+
return await function(session)
|
|
830
|
+
loop = asyncio.get_running_loop()
|
|
831
|
+
result = await loop.run_in_executor(self._eval_executor, function, session)
|
|
832
|
+
if inspect.isawaitable(result):
|
|
833
|
+
return await result
|
|
834
|
+
return result
|
|
835
|
+
|
|
836
|
+
@staticmethod
|
|
837
|
+
def _skipped(definition: EvalDefinition, reason: str) -> SkippedEval:
|
|
838
|
+
return SkippedEval(definition.eval_key, definition.eval_version, reason)
|
|
839
|
+
|
|
840
|
+
@staticmethod
|
|
841
|
+
def _skipped_descriptor(
|
|
842
|
+
definition: AssignmentDefinition, reason: str
|
|
843
|
+
) -> SkippedEval:
|
|
844
|
+
return SkippedEval(definition.eval_key, definition.eval_version, reason)
|
|
845
|
+
|
|
846
|
+
async def _assignment_definitions(
|
|
847
|
+
self, assignment: Assignment
|
|
848
|
+
) -> tuple[AssignmentDefinition, ...]:
|
|
849
|
+
if assignment.definitions_url:
|
|
850
|
+
response = await self._call_client(
|
|
851
|
+
self.client.definitions,
|
|
852
|
+
assignment,
|
|
853
|
+
worker_id=self.config.worker_id,
|
|
854
|
+
)
|
|
855
|
+
if response.assignment_id != assignment.assignment_id:
|
|
856
|
+
raise RuntimeError("server returned definitions for another assignment")
|
|
857
|
+
return response.definitions
|
|
858
|
+
return tuple(
|
|
859
|
+
AssignmentDefinition(
|
|
860
|
+
eval_key=item.eval_key,
|
|
861
|
+
display_name=item.display_name,
|
|
862
|
+
eval_version=item.eval_version,
|
|
863
|
+
result_kind=item.result_kind,
|
|
864
|
+
labels=item.labels,
|
|
865
|
+
)
|
|
866
|
+
for item in self.evaluator.definitions
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
def _reap_finished(self) -> None:
|
|
870
|
+
done = {task for task in self._active if task.done()}
|
|
871
|
+
self._active.difference_update(done)
|
|
872
|
+
for task in done:
|
|
873
|
+
self._consume_task(task)
|
|
874
|
+
|
|
875
|
+
@staticmethod
|
|
876
|
+
def _validated_assignments(
|
|
877
|
+
assignments: tuple[Assignment, ...], capacity: int
|
|
878
|
+
) -> tuple[Assignment, ...]:
|
|
879
|
+
if len(assignments) > capacity:
|
|
880
|
+
raise RuntimeError("server returned more assignments than requested")
|
|
881
|
+
assignment_ids = [item.assignment_id for item in assignments]
|
|
882
|
+
if len(assignment_ids) != len(set(assignment_ids)):
|
|
883
|
+
raise RuntimeError("server returned duplicate assignments")
|
|
884
|
+
return assignments
|
|
885
|
+
|
|
886
|
+
@staticmethod
|
|
887
|
+
def _consume_task(task: asyncio.Task[None]) -> None:
|
|
888
|
+
try:
|
|
889
|
+
task.result()
|
|
890
|
+
except asyncio.CancelledError:
|
|
891
|
+
pass
|
|
892
|
+
except Exception:
|
|
893
|
+
logger.exception("evaluator assignment failed")
|
|
894
|
+
|
|
895
|
+
async def _wait_for_progress(self) -> None:
|
|
896
|
+
if not self._active:
|
|
897
|
+
return
|
|
898
|
+
stop_task = asyncio.create_task(self._stopping.wait())
|
|
899
|
+
try:
|
|
900
|
+
await asyncio.wait(
|
|
901
|
+
(*self._active, stop_task), return_when=asyncio.FIRST_COMPLETED
|
|
902
|
+
)
|
|
903
|
+
finally:
|
|
904
|
+
if not stop_task.done():
|
|
905
|
+
stop_task.cancel()
|
|
906
|
+
await asyncio.gather(stop_task, return_exceptions=True)
|
|
907
|
+
|
|
908
|
+
async def _wait_or_stop(self, seconds: float) -> None:
|
|
909
|
+
try:
|
|
910
|
+
await asyncio.wait_for(self._stopping.wait(), timeout=seconds)
|
|
911
|
+
except asyncio.TimeoutError:
|
|
912
|
+
pass
|
|
913
|
+
|
|
914
|
+
async def _call_client(self, function, *args, **kwargs):
|
|
915
|
+
result = await asyncio.to_thread(function, *args, **kwargs)
|
|
916
|
+
self._last_server_contact = time.monotonic()
|
|
917
|
+
return result
|
|
918
|
+
|
|
919
|
+
def _increment(self, name: str, amount: int = 1) -> None:
|
|
920
|
+
with self._metric_lock:
|
|
921
|
+
self._metrics[name] = self._metrics.get(name, 0) + amount
|
|
922
|
+
|
|
923
|
+
def metrics(self) -> dict[str, int]:
|
|
924
|
+
with self._metric_lock:
|
|
925
|
+
return dict(self._metrics)
|
|
926
|
+
|
|
927
|
+
def is_ready(self) -> bool:
|
|
928
|
+
if (
|
|
929
|
+
self._stopping.is_set()
|
|
930
|
+
or not self._registered
|
|
931
|
+
or self._last_server_contact is None
|
|
932
|
+
):
|
|
933
|
+
return False
|
|
934
|
+
return time.monotonic() - self._last_server_contact <= max(
|
|
935
|
+
float(self._lease_duration), 60.0
|
|
936
|
+
)
|