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,1103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from failproofai_sdk.evaluator import (
|
|
13
|
+
AssignmentDefinition,
|
|
14
|
+
ClaimResponse,
|
|
15
|
+
ConditionResult,
|
|
16
|
+
DefinitionsResponse,
|
|
17
|
+
EvalResult,
|
|
18
|
+
Evaluator,
|
|
19
|
+
EvaluatorAPIError,
|
|
20
|
+
ExecutionMode,
|
|
21
|
+
HeartbeatResponse,
|
|
22
|
+
PlannedRun,
|
|
23
|
+
PlanResponse,
|
|
24
|
+
RegisterResponse,
|
|
25
|
+
ResultKind,
|
|
26
|
+
Score,
|
|
27
|
+
SessionTranscript,
|
|
28
|
+
WorkerConfig,
|
|
29
|
+
WorkerRuntime,
|
|
30
|
+
source_checksum,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
FIXTURE = Path(__file__).parent / "fixtures" / "evaluator_v2" / "contract.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _samples():
|
|
37
|
+
return json.loads(FIXTURE.read_text(encoding="utf-8"))["samples"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FakeClient:
|
|
41
|
+
def __init__(self):
|
|
42
|
+
samples = _samples()
|
|
43
|
+
self.assignment = ClaimResponse.from_wire(
|
|
44
|
+
samples["claim_response"]
|
|
45
|
+
).assignments[0]
|
|
46
|
+
self.assignment = replace(self.assignment, definitions_url="")
|
|
47
|
+
self.session = SessionTranscript.from_wire(samples["transcript_response"])
|
|
48
|
+
self.register_requests = []
|
|
49
|
+
self.claim_requests = []
|
|
50
|
+
self.plans = []
|
|
51
|
+
self.submissions = []
|
|
52
|
+
self.heartbeats = []
|
|
53
|
+
|
|
54
|
+
def register(self, request):
|
|
55
|
+
self.register_requests.append(request)
|
|
56
|
+
return RegisterResponse.from_wire(_samples()["register_response"])
|
|
57
|
+
|
|
58
|
+
def claim(self, request):
|
|
59
|
+
self.claim_requests.append(request)
|
|
60
|
+
return ClaimResponse(assignments=(self.assignment,))
|
|
61
|
+
|
|
62
|
+
def transcript(self, assignment, *, worker_id):
|
|
63
|
+
assert assignment == self.assignment
|
|
64
|
+
assert worker_id == "worker-test"
|
|
65
|
+
return self.session
|
|
66
|
+
|
|
67
|
+
def plan(self, assignment_id, request):
|
|
68
|
+
self.plans.append(request)
|
|
69
|
+
return PlanResponse(
|
|
70
|
+
assignment_id=assignment_id,
|
|
71
|
+
assignment_status="planned" if request.selected else "skipped",
|
|
72
|
+
runs=tuple(
|
|
73
|
+
PlannedRun(f"run-{item.eval_key}", item.eval_key, item.eval_version)
|
|
74
|
+
for item in request.selected
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def submit_result(self, run_id, request):
|
|
79
|
+
self.submissions.append((run_id, request))
|
|
80
|
+
|
|
81
|
+
def heartbeat(self, request):
|
|
82
|
+
self.heartbeats.append(request)
|
|
83
|
+
return HeartbeatResponse(
|
|
84
|
+
lease_expires_at="2026-08-28T12:02:30.000000Z",
|
|
85
|
+
accepted_run_ids=tuple(item.evaluation_run_id for item in request.runs),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _runtime(evaluator, client):
|
|
90
|
+
return WorkerRuntime(
|
|
91
|
+
evaluator,
|
|
92
|
+
WorkerConfig(
|
|
93
|
+
server_url="https://cloud.example",
|
|
94
|
+
credential="secret",
|
|
95
|
+
worker_id="worker-test",
|
|
96
|
+
max_concurrency=2,
|
|
97
|
+
),
|
|
98
|
+
client=client,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_managed_definition_is_fetched_verified_and_executed():
|
|
103
|
+
source = "EvalResult(score=Score(0.75, passed=True), summary='hosted')"
|
|
104
|
+
|
|
105
|
+
class HostedClient(FakeClient):
|
|
106
|
+
def __init__(self):
|
|
107
|
+
super().__init__()
|
|
108
|
+
self.assignment = replace(
|
|
109
|
+
self.assignment,
|
|
110
|
+
definitions_url=f"/v1/evaluator/assignments/{self.assignment.assignment_id}/definitions",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def definitions(self, assignment, *, worker_id):
|
|
114
|
+
assert assignment == self.assignment
|
|
115
|
+
assert worker_id == "worker-test"
|
|
116
|
+
return DefinitionsResponse(
|
|
117
|
+
assignment_id=assignment.assignment_id,
|
|
118
|
+
catalog_revision="sha256:hosted",
|
|
119
|
+
definitions=(
|
|
120
|
+
AssignmentDefinition(
|
|
121
|
+
eval_key="hosted_quality",
|
|
122
|
+
display_name="Hosted quality",
|
|
123
|
+
eval_version="1",
|
|
124
|
+
result_kind=ResultKind.SCORE,
|
|
125
|
+
execution_mode=ExecutionMode.PYTHON,
|
|
126
|
+
source_checksum=source_checksum(None, source),
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def plan(self, assignment_id, request):
|
|
132
|
+
self.plans.append(request)
|
|
133
|
+
return PlanResponse(
|
|
134
|
+
assignment_id=assignment_id,
|
|
135
|
+
assignment_status="planned",
|
|
136
|
+
runs=(
|
|
137
|
+
PlannedRun(
|
|
138
|
+
"run-hosted",
|
|
139
|
+
"hosted_quality",
|
|
140
|
+
"1",
|
|
141
|
+
execution_mode=ExecutionMode.PYTHON,
|
|
142
|
+
evaluator_source=source,
|
|
143
|
+
source_checksum=source_checksum(None, source),
|
|
144
|
+
timeout_seconds=1,
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
client = HostedClient()
|
|
150
|
+
asyncio.run(
|
|
151
|
+
_runtime(Evaluator(name="managed", version="1"), client).process_assignment(
|
|
152
|
+
client.assignment
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
assert len(client.submissions) == 1
|
|
157
|
+
run_id, result = client.submissions[0]
|
|
158
|
+
assert run_id == "run-hosted"
|
|
159
|
+
assert result.status.value == "succeeded"
|
|
160
|
+
assert result.summary == "hosted"
|
|
161
|
+
assert result.results[0].numeric_value == 0.75
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_managed_definition_that_fails_to_compile_dead_letters_as_one_failed_run():
|
|
165
|
+
# Unsafe/malformed server-authored source is rejected by the sandbox at
|
|
166
|
+
# compile time. That rejection must surface as a single bounded FAILED run,
|
|
167
|
+
# NOT as an exception out of assignment setup that crashes the task and
|
|
168
|
+
# forces the whole assignment to be reclaimed and retried.
|
|
169
|
+
unsafe = (
|
|
170
|
+
'EvalResult(score=Score(1.0), '
|
|
171
|
+
'reasoning="{0.__class__}".format(session))'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
class HostedClient(FakeClient):
|
|
175
|
+
def __init__(self):
|
|
176
|
+
super().__init__()
|
|
177
|
+
self.assignment = replace(
|
|
178
|
+
self.assignment,
|
|
179
|
+
definitions_url=f"/v1/evaluator/assignments/{self.assignment.assignment_id}/definitions",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def definitions(self, assignment, *, worker_id):
|
|
183
|
+
return DefinitionsResponse(
|
|
184
|
+
assignment_id=assignment.assignment_id,
|
|
185
|
+
catalog_revision="sha256:hosted",
|
|
186
|
+
definitions=(
|
|
187
|
+
AssignmentDefinition(
|
|
188
|
+
eval_key="hosted_quality",
|
|
189
|
+
display_name="Hosted quality",
|
|
190
|
+
eval_version="1",
|
|
191
|
+
result_kind=ResultKind.SCORE,
|
|
192
|
+
execution_mode=ExecutionMode.PYTHON,
|
|
193
|
+
source_checksum=source_checksum(None, unsafe),
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def plan(self, assignment_id, request):
|
|
199
|
+
self.plans.append(request)
|
|
200
|
+
return PlanResponse(
|
|
201
|
+
assignment_id=assignment_id,
|
|
202
|
+
assignment_status="planned",
|
|
203
|
+
runs=(
|
|
204
|
+
PlannedRun(
|
|
205
|
+
"run-hosted",
|
|
206
|
+
"hosted_quality",
|
|
207
|
+
"1",
|
|
208
|
+
execution_mode=ExecutionMode.PYTHON,
|
|
209
|
+
evaluator_source=unsafe,
|
|
210
|
+
source_checksum=source_checksum(None, unsafe),
|
|
211
|
+
timeout_seconds=1,
|
|
212
|
+
),
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
client = HostedClient()
|
|
217
|
+
# Must NOT raise — the poison definition is contained to its own run.
|
|
218
|
+
asyncio.run(
|
|
219
|
+
_runtime(Evaluator(name="managed", version="1"), client).process_assignment(
|
|
220
|
+
client.assignment
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
assert len(client.submissions) == 1
|
|
225
|
+
run_id, result = client.submissions[0]
|
|
226
|
+
assert run_id == "run-hosted"
|
|
227
|
+
assert result.status.value == "failed"
|
|
228
|
+
assert result.error_code == "eval_error"
|
|
229
|
+
# Nothing derived from the rejected source may be reported.
|
|
230
|
+
assert result.results == ()
|
|
231
|
+
assert result.summary is None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_managed_condition_governs_even_when_a_local_key_collides():
|
|
235
|
+
# COR-001: `local` is keyed on (eval_key, eval_version) alone, so a managed
|
|
236
|
+
# (PYTHON) definition can collide with a local one the worker also registered.
|
|
237
|
+
# The server's managed condition must decide applicability — NOT the matching
|
|
238
|
+
# local condition. Here the local condition returns True and the managed
|
|
239
|
+
# `condition_source` is "False": the definition must be recorded as skipped
|
|
240
|
+
# (condition_false) and the managed evaluator source must never run.
|
|
241
|
+
source = "EvalResult(score=Score(1.0), summary='should never run')"
|
|
242
|
+
|
|
243
|
+
evaluator = Evaluator(name="managed", version="1")
|
|
244
|
+
|
|
245
|
+
@evaluator.eval("hosted_quality", version="1", when=lambda session: True)
|
|
246
|
+
def hosted_quality(session): # a colliding LOCAL definition, condition True
|
|
247
|
+
return EvalResult(score=Score(1.0, passed=True), summary="local")
|
|
248
|
+
|
|
249
|
+
class HostedClient(FakeClient):
|
|
250
|
+
def __init__(self):
|
|
251
|
+
super().__init__()
|
|
252
|
+
self.assignment = replace(
|
|
253
|
+
self.assignment,
|
|
254
|
+
definitions_url=f"/v1/evaluator/assignments/{self.assignment.assignment_id}/definitions",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def definitions(self, assignment, *, worker_id):
|
|
258
|
+
return DefinitionsResponse(
|
|
259
|
+
assignment_id=assignment.assignment_id,
|
|
260
|
+
catalog_revision="sha256:hosted",
|
|
261
|
+
definitions=(
|
|
262
|
+
AssignmentDefinition(
|
|
263
|
+
eval_key="hosted_quality",
|
|
264
|
+
display_name="Hosted quality",
|
|
265
|
+
eval_version="1",
|
|
266
|
+
result_kind=ResultKind.SCORE,
|
|
267
|
+
execution_mode=ExecutionMode.PYTHON,
|
|
268
|
+
condition_source="False",
|
|
269
|
+
source_checksum=source_checksum("False", source),
|
|
270
|
+
timeout_seconds=1,
|
|
271
|
+
),
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
client = HostedClient()
|
|
276
|
+
asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
|
|
277
|
+
|
|
278
|
+
# The server's managed condition (False) wins over the local one (True):
|
|
279
|
+
# recorded as skipped, nothing selected, and no managed run submitted.
|
|
280
|
+
assert client.plans[0].selected == ()
|
|
281
|
+
assert {(item.eval_key, item.reason_code) for item in client.plans[0].skipped} == {
|
|
282
|
+
("hosted_quality", "condition_false"),
|
|
283
|
+
}
|
|
284
|
+
assert client.submissions == []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_two_assignments_share_the_bounded_sync_eval_pool_and_keep_heartbeating():
|
|
288
|
+
evaluator = Evaluator(name="parallel", version="1")
|
|
289
|
+
lock = threading.Lock()
|
|
290
|
+
active = 0
|
|
291
|
+
peak = 0
|
|
292
|
+
|
|
293
|
+
def measured(_session):
|
|
294
|
+
nonlocal active, peak
|
|
295
|
+
with lock:
|
|
296
|
+
active += 1
|
|
297
|
+
peak = max(peak, active)
|
|
298
|
+
time.sleep(0.04)
|
|
299
|
+
with lock:
|
|
300
|
+
active -= 1
|
|
301
|
+
return EvalResult(score=Score(1))
|
|
302
|
+
|
|
303
|
+
for index in range(5):
|
|
304
|
+
evaluator.eval(
|
|
305
|
+
f"eval_{index}",
|
|
306
|
+
version="1",
|
|
307
|
+
when=lambda session, index=index: (
|
|
308
|
+
index < 3 if session.session_id == "session-a" else index >= 3
|
|
309
|
+
),
|
|
310
|
+
)(measured)
|
|
311
|
+
|
|
312
|
+
class ParallelClient(FakeClient):
|
|
313
|
+
def transcript(self, assignment, *, worker_id):
|
|
314
|
+
assert worker_id == "worker-test"
|
|
315
|
+
return replace(
|
|
316
|
+
self.session,
|
|
317
|
+
assignment_id=assignment.assignment_id,
|
|
318
|
+
session_id=assignment.session_id,
|
|
319
|
+
session_revision_id=assignment.session_revision_id,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
client = ParallelClient()
|
|
323
|
+
first = replace(
|
|
324
|
+
client.assignment,
|
|
325
|
+
assignment_id="assignment-a",
|
|
326
|
+
session_id="session-a",
|
|
327
|
+
session_revision_id="revision-a",
|
|
328
|
+
)
|
|
329
|
+
second = replace(
|
|
330
|
+
client.assignment,
|
|
331
|
+
assignment_id="assignment-b",
|
|
332
|
+
session_id="session-b",
|
|
333
|
+
session_revision_id="revision-b",
|
|
334
|
+
)
|
|
335
|
+
runtime = _runtime(evaluator, client)
|
|
336
|
+
runtime._heartbeat_interval = 0.01
|
|
337
|
+
|
|
338
|
+
async def exercise():
|
|
339
|
+
await asyncio.gather(
|
|
340
|
+
runtime.process_assignment(first), runtime.process_assignment(second)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
asyncio.run(exercise())
|
|
344
|
+
|
|
345
|
+
assert peak == 2
|
|
346
|
+
assert len(client.submissions) == 5
|
|
347
|
+
assert client.heartbeats
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_condition_failures_are_isolated_and_plan_is_declared_first():
|
|
351
|
+
evaluator = Evaluator(name="test", version="1")
|
|
352
|
+
|
|
353
|
+
@evaluator.eval("selected", version="1", when=lambda session: True)
|
|
354
|
+
def selected(session):
|
|
355
|
+
return EvalResult(score=Score(1))
|
|
356
|
+
|
|
357
|
+
@evaluator.eval("not_applicable", version="1", when=lambda session: False)
|
|
358
|
+
def not_applicable(session):
|
|
359
|
+
return EvalResult(score=Score(1))
|
|
360
|
+
|
|
361
|
+
def broken_condition(session):
|
|
362
|
+
raise RuntimeError("condition exploded")
|
|
363
|
+
|
|
364
|
+
@evaluator.eval("broken_condition", version="1", when=broken_condition)
|
|
365
|
+
def never_runs(session):
|
|
366
|
+
raise AssertionError("must not run")
|
|
367
|
+
|
|
368
|
+
client = FakeClient()
|
|
369
|
+
asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
|
|
370
|
+
|
|
371
|
+
assert len(client.plans) == 1
|
|
372
|
+
assert [item.eval_key for item in client.plans[0].selected] == ["selected"]
|
|
373
|
+
assert {(item.eval_key, item.reason_code) for item in client.plans[0].skipped} == {
|
|
374
|
+
("not_applicable", "condition_false"),
|
|
375
|
+
("broken_condition", "condition_error"),
|
|
376
|
+
}
|
|
377
|
+
assert [run_id for run_id, _ in client.submissions] == ["run-selected"]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_condition_can_supply_a_stable_skip_reason():
|
|
381
|
+
evaluator = Evaluator(name="test", version="1")
|
|
382
|
+
|
|
383
|
+
@evaluator.eval(
|
|
384
|
+
"retrieval_only",
|
|
385
|
+
version="1",
|
|
386
|
+
when=lambda session: ConditionResult(False, "no_retrieval_events"),
|
|
387
|
+
)
|
|
388
|
+
def retrieval_only(session):
|
|
389
|
+
raise AssertionError("must not run")
|
|
390
|
+
|
|
391
|
+
client = FakeClient()
|
|
392
|
+
asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
|
|
393
|
+
assert client.plans[0].skipped[0].reason_code == "no_retrieval_events"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_one_eval_failure_does_not_block_another_result():
|
|
397
|
+
evaluator = Evaluator(name="test", version="1")
|
|
398
|
+
|
|
399
|
+
@evaluator.eval("fails", version="1")
|
|
400
|
+
def fails(session):
|
|
401
|
+
raise RuntimeError("secret details should be bounded")
|
|
402
|
+
|
|
403
|
+
@evaluator.eval("succeeds", version="1")
|
|
404
|
+
async def succeeds(session):
|
|
405
|
+
await asyncio.sleep(0)
|
|
406
|
+
return EvalResult(score=Score(0.8), summary="good")
|
|
407
|
+
|
|
408
|
+
client = FakeClient()
|
|
409
|
+
asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
|
|
410
|
+
|
|
411
|
+
by_run = {run_id: request for run_id, request in client.submissions}
|
|
412
|
+
assert by_run["run-fails"].status.value == "failed"
|
|
413
|
+
assert by_run["run-fails"].error_code == "eval_error"
|
|
414
|
+
assert by_run["run-fails"].results == ()
|
|
415
|
+
assert by_run["run-succeeds"].status.value == "succeeded"
|
|
416
|
+
assert by_run["run-succeeds"].results[0].result_kind == ResultKind.SCORE
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_timeout_is_submitted_as_a_terminal_run():
|
|
420
|
+
evaluator = Evaluator(name="test", version="1")
|
|
421
|
+
cancelled = []
|
|
422
|
+
|
|
423
|
+
@evaluator.eval(
|
|
424
|
+
"slow",
|
|
425
|
+
version="1",
|
|
426
|
+
timeout_seconds=0.01,
|
|
427
|
+
on_cancel=lambda session: cancelled.append(session.session_revision_id),
|
|
428
|
+
)
|
|
429
|
+
async def slow(session):
|
|
430
|
+
await asyncio.sleep(1)
|
|
431
|
+
return EvalResult(score=Score(1))
|
|
432
|
+
|
|
433
|
+
client = FakeClient()
|
|
434
|
+
asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
|
|
435
|
+
request = client.submissions[0][1]
|
|
436
|
+
assert request.status.value == "timed_out"
|
|
437
|
+
assert request.error_code == "eval_timeout"
|
|
438
|
+
assert cancelled == [client.assignment.session_revision_id]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_lost_lease_cancels_local_execution():
|
|
442
|
+
evaluator = Evaluator(name="test", version="1")
|
|
443
|
+
|
|
444
|
+
@evaluator.eval("slow", version="1")
|
|
445
|
+
async def slow(session):
|
|
446
|
+
await asyncio.sleep(1)
|
|
447
|
+
return EvalResult(score=Score(1))
|
|
448
|
+
|
|
449
|
+
class LeaseLostClient(FakeClient):
|
|
450
|
+
def heartbeat(self, request):
|
|
451
|
+
raise EvaluatorAPIError(
|
|
452
|
+
status=409,
|
|
453
|
+
code="lease_lost",
|
|
454
|
+
message="gone",
|
|
455
|
+
retryable=False,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
client = LeaseLostClient()
|
|
459
|
+
runtime = _runtime(evaluator, client)
|
|
460
|
+
runtime._heartbeat_interval = 0.01
|
|
461
|
+
with pytest.raises(asyncio.CancelledError):
|
|
462
|
+
asyncio.run(runtime.process_assignment(client.assignment))
|
|
463
|
+
assert client.submissions == []
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_partial_heartbeat_acceptance_cancels_only_the_fenced_run():
|
|
467
|
+
evaluator = Evaluator(name="test", version="1")
|
|
468
|
+
|
|
469
|
+
class PartialHeartbeatClient(FakeClient):
|
|
470
|
+
def heartbeat(self, request):
|
|
471
|
+
self.heartbeats.append(request)
|
|
472
|
+
return HeartbeatResponse(
|
|
473
|
+
lease_expires_at="2026-08-28T12:02:30.000000Z",
|
|
474
|
+
accepted_run_ids=(request.runs[0].evaluation_run_id,),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
client = PartialHeartbeatClient()
|
|
478
|
+
runtime = _runtime(evaluator, client)
|
|
479
|
+
runtime._heartbeat_interval = 0.01
|
|
480
|
+
|
|
481
|
+
async def exercise():
|
|
482
|
+
first = asyncio.create_task(asyncio.sleep(60))
|
|
483
|
+
second = asyncio.create_task(asyncio.sleep(60))
|
|
484
|
+
heartbeat = asyncio.create_task(
|
|
485
|
+
runtime._heartbeat(
|
|
486
|
+
client.assignment, {"run-first": first, "run-second": second}
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
while not client.heartbeats:
|
|
490
|
+
await asyncio.sleep(0.001)
|
|
491
|
+
for _ in range(100):
|
|
492
|
+
if second.done():
|
|
493
|
+
break
|
|
494
|
+
await asyncio.sleep(0.001)
|
|
495
|
+
assert first.done() is False
|
|
496
|
+
assert second.cancelled() is True
|
|
497
|
+
heartbeat.cancel()
|
|
498
|
+
first.cancel()
|
|
499
|
+
await asyncio.gather(first, second, heartbeat, return_exceptions=True)
|
|
500
|
+
|
|
501
|
+
asyncio.run(exercise())
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def test_transcript_revision_must_match_the_claimed_assignment():
|
|
505
|
+
evaluator = Evaluator(name="test", version="1")
|
|
506
|
+
client = FakeClient()
|
|
507
|
+
client.session = SessionTranscript.from_wire(
|
|
508
|
+
{
|
|
509
|
+
**_samples()["transcript_response"],
|
|
510
|
+
"session_revision_id": "different-revision",
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
with pytest.raises(RuntimeError, match="revision does not match"):
|
|
515
|
+
asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
|
|
516
|
+
assert client.plans == []
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def test_server_cannot_add_a_run_when_every_eval_was_skipped():
|
|
520
|
+
evaluator = Evaluator(name="test", version="1")
|
|
521
|
+
|
|
522
|
+
@evaluator.eval("skipped", version="1", when=lambda session: False)
|
|
523
|
+
def skipped(session):
|
|
524
|
+
raise AssertionError("must not run")
|
|
525
|
+
|
|
526
|
+
class UnexpectedRunClient(FakeClient):
|
|
527
|
+
def plan(self, assignment_id, request):
|
|
528
|
+
self.plans.append(request)
|
|
529
|
+
return PlanResponse(
|
|
530
|
+
assignment_id=assignment_id,
|
|
531
|
+
assignment_status="skipped",
|
|
532
|
+
runs=(PlannedRun("run-injected", "skipped", "1"),),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
client = UnexpectedRunClient()
|
|
536
|
+
with pytest.raises(RuntimeError, match="unrequested evaluation run"):
|
|
537
|
+
asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
|
|
538
|
+
assert client.submissions == []
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def test_server_plan_must_match_assignment_and_include_each_new_selected_eval():
|
|
542
|
+
evaluator = Evaluator(name="test", version="1")
|
|
543
|
+
|
|
544
|
+
@evaluator.eval("selected", version="1")
|
|
545
|
+
def selected(session):
|
|
546
|
+
return EvalResult(score=Score(1))
|
|
547
|
+
|
|
548
|
+
class WrongAssignmentClient(FakeClient):
|
|
549
|
+
def plan(self, assignment_id, request):
|
|
550
|
+
return PlanResponse(
|
|
551
|
+
assignment_id="another-assignment",
|
|
552
|
+
assignment_status="planned",
|
|
553
|
+
runs=(PlannedRun("run-selected", "selected", "1"),),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
wrong_assignment = WrongAssignmentClient()
|
|
557
|
+
with pytest.raises(RuntimeError, match="different assignment"):
|
|
558
|
+
asyncio.run(
|
|
559
|
+
_runtime(evaluator, wrong_assignment).process_assignment(
|
|
560
|
+
wrong_assignment.assignment
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
class WrongStatusClient(FakeClient):
|
|
565
|
+
def plan(self, assignment_id, request):
|
|
566
|
+
return PlanResponse(
|
|
567
|
+
assignment_id=assignment_id,
|
|
568
|
+
assignment_status="skipped",
|
|
569
|
+
runs=(PlannedRun("run-selected", "selected", "1"),),
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
wrong_status = WrongStatusClient()
|
|
573
|
+
with pytest.raises(RuntimeError, match="inconsistent assignment status"):
|
|
574
|
+
asyncio.run(
|
|
575
|
+
_runtime(evaluator, wrong_status).process_assignment(
|
|
576
|
+
wrong_status.assignment
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
class OmittedRunClient(FakeClient):
|
|
581
|
+
def plan(self, assignment_id, request):
|
|
582
|
+
return PlanResponse(
|
|
583
|
+
assignment_id=assignment_id,
|
|
584
|
+
assignment_status="planned",
|
|
585
|
+
runs=(),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
omitted = OmittedRunClient()
|
|
589
|
+
with pytest.raises(RuntimeError, match="omitted a selected evaluation run"):
|
|
590
|
+
asyncio.run(_runtime(evaluator, omitted).process_assignment(omitted.assignment))
|
|
591
|
+
|
|
592
|
+
class ReplayedPlanClient(FakeClient):
|
|
593
|
+
def plan(self, assignment_id, request):
|
|
594
|
+
return PlanResponse(
|
|
595
|
+
assignment_id=assignment_id,
|
|
596
|
+
assignment_status="planned",
|
|
597
|
+
runs=(),
|
|
598
|
+
idempotent_replay=True,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
replayed = ReplayedPlanClient()
|
|
602
|
+
asyncio.run(_runtime(evaluator, replayed).process_assignment(replayed.assignment))
|
|
603
|
+
assert replayed.submissions == []
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def test_server_plan_rejects_duplicate_run_ids():
|
|
607
|
+
evaluator = Evaluator(name="test", version="1")
|
|
608
|
+
evaluator.eval("first", version="1")(lambda session: EvalResult(score=Score(1)))
|
|
609
|
+
evaluator.eval("second", version="1")(lambda session: EvalResult(score=Score(1)))
|
|
610
|
+
|
|
611
|
+
class DuplicateRunClient(FakeClient):
|
|
612
|
+
def plan(self, assignment_id, request):
|
|
613
|
+
return PlanResponse(
|
|
614
|
+
assignment_id=assignment_id,
|
|
615
|
+
assignment_status="planned",
|
|
616
|
+
runs=(
|
|
617
|
+
PlannedRun("same-run", "first", "1"),
|
|
618
|
+
PlannedRun("same-run", "second", "1"),
|
|
619
|
+
),
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
client = DuplicateRunClient()
|
|
623
|
+
with pytest.raises(RuntimeError, match="duplicate evaluation run id"):
|
|
624
|
+
asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
|
|
625
|
+
assert client.submissions == []
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def test_register_advertises_the_deterministic_catalog():
|
|
629
|
+
evaluator = Evaluator(name="test", version="1")
|
|
630
|
+
|
|
631
|
+
@evaluator.eval("quality", version="7")
|
|
632
|
+
def quality(session):
|
|
633
|
+
return EvalResult(score=Score(1))
|
|
634
|
+
|
|
635
|
+
client = FakeClient()
|
|
636
|
+
runtime = _runtime(evaluator, client)
|
|
637
|
+
asyncio.run(runtime.register())
|
|
638
|
+
request = client.register_requests[0]
|
|
639
|
+
assert request.catalog_revision == evaluator.catalog_revision
|
|
640
|
+
assert request.definitions[0].eval_version == "7"
|
|
641
|
+
assert runtime._heartbeat_interval == 30
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def test_runtime_readiness_tracks_registration_contact_and_shutdown(monkeypatch):
|
|
645
|
+
evaluator = Evaluator(name="test", version="1")
|
|
646
|
+
runtime = _runtime(evaluator, FakeClient())
|
|
647
|
+
|
|
648
|
+
assert runtime.is_ready() is False
|
|
649
|
+
assert runtime.metrics() == {}
|
|
650
|
+
|
|
651
|
+
asyncio.run(runtime.register())
|
|
652
|
+
assert runtime.is_ready() is True
|
|
653
|
+
assert runtime.metrics() == {"registration_success": 1}
|
|
654
|
+
|
|
655
|
+
last_contact = runtime._last_server_contact
|
|
656
|
+
assert last_contact is not None
|
|
657
|
+
monkeypatch.setattr(time, "monotonic", lambda: last_contact + 121)
|
|
658
|
+
assert runtime.is_ready() is False
|
|
659
|
+
|
|
660
|
+
monkeypatch.setattr(time, "monotonic", lambda: last_contact)
|
|
661
|
+
runtime.stop()
|
|
662
|
+
assert runtime.is_ready() is False
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def test_runtime_metrics_count_claims_conditions_and_outcomes():
|
|
666
|
+
evaluator = Evaluator(name="test", version="1")
|
|
667
|
+
|
|
668
|
+
@evaluator.eval("selected", version="1", when=lambda session: True)
|
|
669
|
+
def selected(session):
|
|
670
|
+
return EvalResult(score=Score(1))
|
|
671
|
+
|
|
672
|
+
@evaluator.eval("skipped", version="1", when=lambda session: False)
|
|
673
|
+
def skipped(session):
|
|
674
|
+
raise AssertionError("must not run")
|
|
675
|
+
|
|
676
|
+
runtime = _runtime(evaluator, FakeClient())
|
|
677
|
+
|
|
678
|
+
async def exercise():
|
|
679
|
+
await runtime.register()
|
|
680
|
+
return await runtime.run_once()
|
|
681
|
+
|
|
682
|
+
assert asyncio.run(exercise()) == 1
|
|
683
|
+
assert runtime.metrics() == {
|
|
684
|
+
"assignments_claimed": 1,
|
|
685
|
+
"conditions_selected": 1,
|
|
686
|
+
"conditions_skipped": 1,
|
|
687
|
+
"registration_success": 1,
|
|
688
|
+
"runs_succeeded": 1,
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def test_runtime_metrics_count_registration_failure():
|
|
693
|
+
evaluator = Evaluator(name="test", version="1")
|
|
694
|
+
|
|
695
|
+
class BrokenClient(FakeClient):
|
|
696
|
+
def register(self, request):
|
|
697
|
+
raise EvaluatorAPIError(
|
|
698
|
+
status=503,
|
|
699
|
+
code="unavailable",
|
|
700
|
+
message="try later",
|
|
701
|
+
retryable=True,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
runtime = _runtime(evaluator, BrokenClient())
|
|
705
|
+
with pytest.raises(EvaluatorAPIError):
|
|
706
|
+
asyncio.run(runtime.register())
|
|
707
|
+
assert runtime.is_ready() is False
|
|
708
|
+
assert runtime.metrics() == {"registration_failure": 1}
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def test_invalid_registration_response_does_not_make_runtime_ready():
|
|
712
|
+
evaluator = Evaluator(name="test", version="1")
|
|
713
|
+
|
|
714
|
+
class InvalidTimingClient(FakeClient):
|
|
715
|
+
def register(self, request):
|
|
716
|
+
return RegisterResponse(
|
|
717
|
+
evaluator_instance_id="instance",
|
|
718
|
+
evaluator_kind=self._kind(),
|
|
719
|
+
heartbeat_interval_seconds=120,
|
|
720
|
+
lease_duration_seconds=120,
|
|
721
|
+
poll_interval_seconds=10,
|
|
722
|
+
claim_limit=1,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
@staticmethod
|
|
726
|
+
def _kind():
|
|
727
|
+
from failproofai_sdk.evaluator import EvaluatorKind
|
|
728
|
+
|
|
729
|
+
return EvaluatorKind.CUSTOMER
|
|
730
|
+
|
|
731
|
+
runtime = _runtime(evaluator, InvalidTimingClient())
|
|
732
|
+
with pytest.raises(RuntimeError, match="invalid evaluator timing"):
|
|
733
|
+
asyncio.run(runtime.register())
|
|
734
|
+
assert runtime.is_ready() is False
|
|
735
|
+
assert runtime.metrics() == {"registration_failure": 1}
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def test_lost_claim_response_waits_out_the_lease_before_claiming_again():
|
|
739
|
+
evaluator = Evaluator(name="test", version="1")
|
|
740
|
+
|
|
741
|
+
class LostResponseClient(FakeClient):
|
|
742
|
+
def claim(self, request):
|
|
743
|
+
self.claim_requests.append(request)
|
|
744
|
+
raise EvaluatorAPIError(
|
|
745
|
+
status=None,
|
|
746
|
+
code="transport_error",
|
|
747
|
+
message="response lost",
|
|
748
|
+
retryable=True,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
runtime = _runtime(evaluator, LostResponseClient())
|
|
752
|
+
waits = []
|
|
753
|
+
|
|
754
|
+
async def stop_after_wait(seconds):
|
|
755
|
+
waits.append(seconds)
|
|
756
|
+
runtime.stop()
|
|
757
|
+
|
|
758
|
+
runtime._wait_or_stop = stop_after_wait
|
|
759
|
+
asyncio.run(runtime.run_forever())
|
|
760
|
+
|
|
761
|
+
assert waits == [120.0]
|
|
762
|
+
assert len(runtime.client.claim_requests) == 1
|
|
763
|
+
assert runtime.metrics() == {
|
|
764
|
+
"claim_failures": 1,
|
|
765
|
+
"registration_success": 1,
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def test_idle_claim_waits_the_advertised_poll_interval_before_polling_again():
|
|
770
|
+
# Normal short polling: an empty claim returns immediately (no long-poll), so
|
|
771
|
+
# the worker sleeps the server-advertised poll_interval_seconds — 10 in the
|
|
772
|
+
# fixture register response — instead of hot-looping. The claim request also no
|
|
773
|
+
# longer carries a wait_seconds field.
|
|
774
|
+
evaluator = Evaluator(name="test", version="1")
|
|
775
|
+
|
|
776
|
+
class IdleClient(FakeClient):
|
|
777
|
+
def claim(self, request):
|
|
778
|
+
self.claim_requests.append(request)
|
|
779
|
+
return ClaimResponse(assignments=())
|
|
780
|
+
|
|
781
|
+
runtime = _runtime(evaluator, IdleClient())
|
|
782
|
+
waits = []
|
|
783
|
+
|
|
784
|
+
async def stop_after_wait(seconds):
|
|
785
|
+
waits.append(seconds)
|
|
786
|
+
runtime.stop()
|
|
787
|
+
|
|
788
|
+
runtime._wait_or_stop = stop_after_wait
|
|
789
|
+
asyncio.run(runtime.run_forever())
|
|
790
|
+
|
|
791
|
+
assert waits == [10.0]
|
|
792
|
+
assert len(runtime.client.claim_requests) == 1
|
|
793
|
+
assert not hasattr(runtime.client.claim_requests[0], "wait_seconds")
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def test_nonretryable_claim_failure_stops_the_worker():
|
|
797
|
+
evaluator = Evaluator(name="test", version="1")
|
|
798
|
+
|
|
799
|
+
class RejectedClaimClient(FakeClient):
|
|
800
|
+
def claim(self, request):
|
|
801
|
+
self.claim_requests.append(request)
|
|
802
|
+
raise EvaluatorAPIError(
|
|
803
|
+
status=409,
|
|
804
|
+
code="catalog_mismatch",
|
|
805
|
+
message="register again with the current catalog",
|
|
806
|
+
retryable=False,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
runtime = _runtime(evaluator, RejectedClaimClient())
|
|
810
|
+
with pytest.raises(EvaluatorAPIError, match="catalog_mismatch"):
|
|
811
|
+
asyncio.run(runtime.run_forever())
|
|
812
|
+
assert len(runtime.client.claim_requests) == 1
|
|
813
|
+
assert runtime.metrics() == {
|
|
814
|
+
"claim_failures": 1,
|
|
815
|
+
"registration_success": 1,
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
@pytest.mark.parametrize(
|
|
820
|
+
("assignments", "message"),
|
|
821
|
+
[
|
|
822
|
+
(lambda item: (item, item), "duplicate assignments"),
|
|
823
|
+
(
|
|
824
|
+
lambda item: tuple(
|
|
825
|
+
replace(item, assignment_id=f"assignment-{index}") for index in range(3)
|
|
826
|
+
),
|
|
827
|
+
"more assignments than requested",
|
|
828
|
+
),
|
|
829
|
+
],
|
|
830
|
+
)
|
|
831
|
+
def test_claim_response_cannot_exceed_capacity_or_repeat_work(assignments, message):
|
|
832
|
+
evaluator = Evaluator(name="test", version="1")
|
|
833
|
+
|
|
834
|
+
class InvalidClaimClient(FakeClient):
|
|
835
|
+
def claim(self, request):
|
|
836
|
+
return ClaimResponse(assignments=assignments(self.assignment))
|
|
837
|
+
|
|
838
|
+
runtime = _runtime(evaluator, InvalidClaimClient())
|
|
839
|
+
with pytest.raises(RuntimeError, match=message):
|
|
840
|
+
asyncio.run(runtime.run_once())
|
|
841
|
+
assert runtime.metrics() == {}
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def test_register_applies_server_claim_limit_and_disabled_definitions():
|
|
845
|
+
evaluator = Evaluator(name="test", version="1")
|
|
846
|
+
|
|
847
|
+
@evaluator.eval("disabled", version="1")
|
|
848
|
+
def disabled(session):
|
|
849
|
+
raise AssertionError("disabled eval must not run")
|
|
850
|
+
|
|
851
|
+
class RestrictedClient(FakeClient):
|
|
852
|
+
def register(self, request):
|
|
853
|
+
self.register_requests.append(request)
|
|
854
|
+
return RegisterResponse(
|
|
855
|
+
evaluator_instance_id="instance",
|
|
856
|
+
evaluator_kind=self._kind(),
|
|
857
|
+
heartbeat_interval_seconds=10,
|
|
858
|
+
lease_duration_seconds=120,
|
|
859
|
+
poll_interval_seconds=10,
|
|
860
|
+
claim_limit=1,
|
|
861
|
+
disabled_definitions=("disabled",),
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
@staticmethod
|
|
865
|
+
def _kind():
|
|
866
|
+
from failproofai_sdk.evaluator import EvaluatorKind
|
|
867
|
+
|
|
868
|
+
return EvaluatorKind.CUSTOMER
|
|
869
|
+
|
|
870
|
+
client = RestrictedClient()
|
|
871
|
+
runtime = _runtime(evaluator, client)
|
|
872
|
+
|
|
873
|
+
async def exercise():
|
|
874
|
+
await runtime.register()
|
|
875
|
+
await runtime.run_once()
|
|
876
|
+
|
|
877
|
+
asyncio.run(exercise())
|
|
878
|
+
assert runtime._claim_limit == 1
|
|
879
|
+
assert client.claim_requests[0].capacity == 1
|
|
880
|
+
assert client.plans[0].selected == ()
|
|
881
|
+
assert client.plans[0].skipped[0].reason_code == "disabled_by_server"
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def test_worker_config_requires_dedicated_credentials(monkeypatch):
|
|
885
|
+
monkeypatch.delenv("FAILPROOFAI_EVALUATOR_URL", raising=False)
|
|
886
|
+
monkeypatch.delenv("FAILPROOFAI_EVALUATOR_TOKEN", raising=False)
|
|
887
|
+
with pytest.raises(ValueError, match="URL is required"):
|
|
888
|
+
WorkerConfig.from_env()
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def test_register_rejects_non_positive_poll_interval():
|
|
892
|
+
# The worker adopts the server-advertised poll_interval_seconds (normal short
|
|
893
|
+
# polling — there is no long-poll wait). A non-positive interval would make the
|
|
894
|
+
# claim loop hot-spin, so registration must refuse it.
|
|
895
|
+
evaluator = Evaluator(name="test", version="1")
|
|
896
|
+
|
|
897
|
+
class ZeroPollClient(FakeClient):
|
|
898
|
+
def register(self, request):
|
|
899
|
+
return RegisterResponse(
|
|
900
|
+
evaluator_instance_id="instance",
|
|
901
|
+
evaluator_kind=self._kind(),
|
|
902
|
+
heartbeat_interval_seconds=30,
|
|
903
|
+
lease_duration_seconds=120,
|
|
904
|
+
poll_interval_seconds=0,
|
|
905
|
+
claim_limit=1,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
@staticmethod
|
|
909
|
+
def _kind():
|
|
910
|
+
from failproofai_sdk.evaluator import EvaluatorKind
|
|
911
|
+
|
|
912
|
+
return EvaluatorKind.CUSTOMER
|
|
913
|
+
|
|
914
|
+
runtime = _runtime(evaluator, ZeroPollClient())
|
|
915
|
+
with pytest.raises(RuntimeError, match="invalid evaluator timing"):
|
|
916
|
+
asyncio.run(runtime.register())
|
|
917
|
+
assert runtime.is_ready() is False
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def test_worker_config_rejects_header_control_characters(monkeypatch):
|
|
921
|
+
monkeypatch.setenv("FAILPROOFAI_EVALUATOR_URL", "https://cloud.example")
|
|
922
|
+
monkeypatch.setenv("FAILPROOFAI_EVALUATOR_TOKEN", "secret")
|
|
923
|
+
monkeypatch.setenv("FAILPROOFAI_EVALUATOR_WORKER_ID", "worker\nforged")
|
|
924
|
+
with pytest.raises(ValueError, match="control characters"):
|
|
925
|
+
WorkerConfig.from_env()
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def test_graceful_drain_cancels_work_after_the_configured_deadline():
|
|
929
|
+
evaluator = Evaluator(name="test", version="1")
|
|
930
|
+
client = FakeClient()
|
|
931
|
+
runtime = WorkerRuntime(
|
|
932
|
+
evaluator,
|
|
933
|
+
WorkerConfig(
|
|
934
|
+
server_url="https://cloud.example",
|
|
935
|
+
credential="secret",
|
|
936
|
+
worker_id="worker-test",
|
|
937
|
+
drain_timeout_seconds=1,
|
|
938
|
+
),
|
|
939
|
+
client=client,
|
|
940
|
+
)
|
|
941
|
+
cancelled = False
|
|
942
|
+
|
|
943
|
+
async def exercise():
|
|
944
|
+
nonlocal cancelled
|
|
945
|
+
|
|
946
|
+
async def active_work():
|
|
947
|
+
nonlocal cancelled
|
|
948
|
+
try:
|
|
949
|
+
await asyncio.sleep(60)
|
|
950
|
+
except asyncio.CancelledError:
|
|
951
|
+
cancelled = True
|
|
952
|
+
raise
|
|
953
|
+
|
|
954
|
+
task = asyncio.create_task(active_work())
|
|
955
|
+
runtime._active.add(task)
|
|
956
|
+
await asyncio.sleep(0)
|
|
957
|
+
runtime.config = WorkerConfig(
|
|
958
|
+
server_url=runtime.config.server_url,
|
|
959
|
+
credential=runtime.config.credential,
|
|
960
|
+
worker_id=runtime.config.worker_id,
|
|
961
|
+
drain_timeout_seconds=0,
|
|
962
|
+
)
|
|
963
|
+
await runtime.drain()
|
|
964
|
+
|
|
965
|
+
asyncio.run(exercise())
|
|
966
|
+
assert cancelled is True
|
|
967
|
+
assert runtime._active == set()
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def test_stop_interrupts_capacity_wait_and_enters_drain():
|
|
971
|
+
runtime = _runtime(Evaluator(name="test", version="1"), FakeClient())
|
|
972
|
+
|
|
973
|
+
async def exercise():
|
|
974
|
+
blocker = asyncio.Event()
|
|
975
|
+
work = asyncio.create_task(blocker.wait())
|
|
976
|
+
runtime._active.add(work)
|
|
977
|
+
await asyncio.sleep(0)
|
|
978
|
+
|
|
979
|
+
runtime.stop()
|
|
980
|
+
await asyncio.wait_for(runtime._wait_for_progress(), timeout=0.1)
|
|
981
|
+
|
|
982
|
+
assert work.done() is False
|
|
983
|
+
work.cancel()
|
|
984
|
+
await asyncio.gather(work, return_exceptions=True)
|
|
985
|
+
|
|
986
|
+
asyncio.run(exercise())
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def test_eval_execution_respects_process_concurrency():
|
|
990
|
+
evaluator = Evaluator(name="test", version="1")
|
|
991
|
+
active = 0
|
|
992
|
+
peak = 0
|
|
993
|
+
|
|
994
|
+
async def measured(session):
|
|
995
|
+
nonlocal active, peak
|
|
996
|
+
active += 1
|
|
997
|
+
peak = max(peak, active)
|
|
998
|
+
await asyncio.sleep(0.01)
|
|
999
|
+
active -= 1
|
|
1000
|
+
return EvalResult(score=Score(1))
|
|
1001
|
+
|
|
1002
|
+
evaluator.eval("first", version="1")(measured)
|
|
1003
|
+
evaluator.eval("second", version="1")(measured)
|
|
1004
|
+
client = FakeClient()
|
|
1005
|
+
runtime = WorkerRuntime(
|
|
1006
|
+
evaluator,
|
|
1007
|
+
WorkerConfig(
|
|
1008
|
+
server_url="https://cloud.example",
|
|
1009
|
+
credential="secret",
|
|
1010
|
+
worker_id="worker-test",
|
|
1011
|
+
max_concurrency=1,
|
|
1012
|
+
),
|
|
1013
|
+
client=client,
|
|
1014
|
+
)
|
|
1015
|
+
asyncio.run(runtime.process_assignment(client.assignment))
|
|
1016
|
+
assert peak == 1
|
|
1017
|
+
DefinitionsResponse,
|
|
1018
|
+
ExecutionMode,
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def test_synchronous_evaluation_timeout_is_counted_as_orphaned():
|
|
1022
|
+
# A synchronous evaluator that overruns its timeout cannot be cancelled: the
|
|
1023
|
+
# runtime submits a terminal timed_out result and records the orphaned thread
|
|
1024
|
+
# so a hung evaluator is findable. The executor is sized with headroom over
|
|
1025
|
+
# the concurrency limit so this orphan does not starve live capacity.
|
|
1026
|
+
evaluator = Evaluator(name="test", version="1")
|
|
1027
|
+
|
|
1028
|
+
@evaluator.eval("slow", version="1", timeout_seconds=0.05)
|
|
1029
|
+
def slow(session):
|
|
1030
|
+
time.sleep(0.5)
|
|
1031
|
+
return EvalResult(score=Score(1))
|
|
1032
|
+
|
|
1033
|
+
client = FakeClient()
|
|
1034
|
+
runtime = _runtime(evaluator, client)
|
|
1035
|
+
try:
|
|
1036
|
+
asyncio.run(runtime.process_assignment(client.assignment))
|
|
1037
|
+
request = client.submissions[0][1]
|
|
1038
|
+
assert request.status.value == "timed_out"
|
|
1039
|
+
assert request.error_code == "eval_timeout"
|
|
1040
|
+
assert runtime.metrics().get("sync_evaluations_orphaned") == 1
|
|
1041
|
+
assert runtime._eval_executor._max_workers > runtime.config.max_concurrency
|
|
1042
|
+
finally:
|
|
1043
|
+
runtime._eval_executor.shutdown(wait=True)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def test_conditions_are_skipped_when_the_lease_is_exhausted():
|
|
1047
|
+
# With no lease time left before the plan must be submitted, the worker skips
|
|
1048
|
+
# the condition (without running it) instead of burning the lease and getting
|
|
1049
|
+
# the plan fenced as lease_lost.
|
|
1050
|
+
evaluator = Evaluator(name="test", version="1")
|
|
1051
|
+
ran = []
|
|
1052
|
+
|
|
1053
|
+
def gate(session):
|
|
1054
|
+
ran.append(True)
|
|
1055
|
+
return True
|
|
1056
|
+
|
|
1057
|
+
@evaluator.eval("slow", version="1", when=gate)
|
|
1058
|
+
def slow(session):
|
|
1059
|
+
return EvalResult(score=Score(1))
|
|
1060
|
+
|
|
1061
|
+
client = FakeClient()
|
|
1062
|
+
runtime = _runtime(evaluator, client)
|
|
1063
|
+
# Force an already-exhausted condition-phase deadline.
|
|
1064
|
+
runtime._condition_phase_deadline = lambda assignment: time.monotonic()
|
|
1065
|
+
asyncio.run(runtime.process_assignment(client.assignment))
|
|
1066
|
+
|
|
1067
|
+
assert ran == [], "the condition must not run once the lease is exhausted"
|
|
1068
|
+
assert client.plans, "a plan must still be submitted"
|
|
1069
|
+
plan_request = client.plans[-1]
|
|
1070
|
+
assert not plan_request.selected
|
|
1071
|
+
reasons = {(s.eval_key, s.reason_code) for s in plan_request.skipped}
|
|
1072
|
+
assert ("slow", "lease_exhausted") in reasons
|
|
1073
|
+
assert runtime.metrics().get("conditions_lease_exhausted") == 1
|
|
1074
|
+
assert client.submissions == []
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def test_condition_phase_deadline_and_budget_are_lease_bounded():
|
|
1078
|
+
from datetime import datetime, timedelta, timezone
|
|
1079
|
+
|
|
1080
|
+
evaluator = Evaluator(name="test", version="1")
|
|
1081
|
+
client = FakeClient()
|
|
1082
|
+
runtime = _runtime(evaluator, client)
|
|
1083
|
+
runtime._lease_duration = 120
|
|
1084
|
+
|
|
1085
|
+
# A stale (past) lease_expires_at falls back to the negotiated lease duration,
|
|
1086
|
+
# so the bound never fires spuriously under clock skew or a replayed fixture.
|
|
1087
|
+
stale = replace(client.assignment, lease_expires_at="2000-01-01T00:00:00.000000Z")
|
|
1088
|
+
fallback = runtime._condition_phase_deadline(stale) - time.monotonic()
|
|
1089
|
+
assert 110 <= fallback <= 125
|
|
1090
|
+
|
|
1091
|
+
# A future lease is honored.
|
|
1092
|
+
future_ts = (datetime.now(timezone.utc) + timedelta(seconds=300)).strftime(
|
|
1093
|
+
"%Y-%m-%dT%H:%M:%S.%f"
|
|
1094
|
+
) + "Z"
|
|
1095
|
+
future = replace(client.assignment, lease_expires_at=future_ts)
|
|
1096
|
+
ahead = runtime._condition_phase_deadline(future) - time.monotonic()
|
|
1097
|
+
assert 250 <= ahead <= 305
|
|
1098
|
+
|
|
1099
|
+
# Budget is capped by both the remaining lease and the per-definition timeout.
|
|
1100
|
+
deadline = time.monotonic() + 100
|
|
1101
|
+
assert runtime._condition_budget(deadline, None) == pytest.approx(95, abs=2)
|
|
1102
|
+
assert runtime._condition_budget(deadline, 10) == pytest.approx(10, abs=0.05)
|
|
1103
|
+
assert runtime._condition_budget(time.monotonic(), None) < 0
|