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.
Files changed (251) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/prerender-manifest.json +5 -5
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/invite/route_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route_client-reference-manifest.js +1 -1
  28. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/status/route_client-reference-manifest.js +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-verify/route_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +37 -4
  42. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  43. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/index.html +1 -1
  46. package/.next/standalone/.next/server/app/index.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  48. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  56. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  72. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  73. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[externals]__0-9ti7z._.js → [externals]__1lh7m5d._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/{[externals]__0u3wrvq._.js → [externals]__1rqkg_y._.js} +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/{_1-akg6w._.js → _08w6xzm._.js} +2 -2
  80. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
  83. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +23 -0
  99. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/_0l2hi_d._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +47 -13
  103. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -69
  106. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_rerun-button_tsx_0blihgw._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  115. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  116. package/.next/standalone/.next/server/pages/404.html +1 -1
  117. package/.next/standalone/.next/server/pages/500.html +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.json +54 -21
  120. package/.next/standalone/.next/static/chunks/010bv1w6j171t.js +1 -0
  121. package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +1 -0
  122. package/.next/standalone/.next/static/chunks/{29-iqnp9v_axt.css → 0o-hh5_turzlz.css} +1 -1
  123. package/.next/standalone/.next/static/chunks/{40nr6ehj4p3cw.css → 0qmsunv04x4na.css} +1 -1
  124. package/.next/standalone/.next/static/chunks/{0ollg-nnu3t3e.js → 0wz8yftk18ts2.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +1 -0
  126. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1pb1oztsbwcss.js +1 -0
  128. package/.next/standalone/.next/static/chunks/{1-_zkmnm-jwei.js → 2aquitk72k2op.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/2bi_1y0a_smt7.js +6 -0
  130. package/.next/standalone/.next/static/chunks/{42yfkhekx440s.js → 2ej3b8gk5ittu.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{354-n7p7labpn.js → 2zafgs90r_leq.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{2xm5mj1d21b4n.js → 32spub4wqjem-.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{1a6u7l3giejch.js → 3m4upvybtrexd.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/{2k9f4tyv04809.css → 3uyhmm01g64k4.css} +1 -0
  135. package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-3s3c-u_u_l0u9.js} +1 -1
  136. package/.next/standalone/.opencode/plugins/failproofai.mjs +51 -27
  137. package/.next/standalone/app/actions/get-leaks.ts +100 -0
  138. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +112 -33
  139. package/.next/standalone/app/audit/_components/audit-poster.tsx +29 -10
  140. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +6 -1
  141. package/.next/standalone/app/audit/_components/empty-state.tsx +4 -1
  142. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +19 -5
  143. package/.next/standalone/app/audit/_components/leak-section.tsx +179 -0
  144. package/.next/standalone/app/audit/_components/share-templates.ts +110 -44
  145. package/.next/standalone/app/audit/audit-styles.css +114 -1
  146. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +26 -3
  147. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +31 -8
  148. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +1 -0
  149. package/.next/standalone/fp-cloud-cli/pyproject.toml +9 -2
  150. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +54 -0
  151. package/.next/standalone/fp-cloud-cli/uv.lock +9 -12
  152. package/.next/standalone/lib/auth/api-server-client.ts +28 -0
  153. package/.next/standalone/lib/claude-sessions.ts +101 -27
  154. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  155. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  156. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
  157. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
  158. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +1 -1
  159. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +5 -5
  160. package/.next/standalone/node_modules/next/dist/experimental/testmode/fetch.js +10 -2
  161. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  162. package/.next/standalone/node_modules/next/dist/lib/typescript/runTypeScriptCli.js +2 -2
  163. package/.next/standalone/node_modules/next/dist/lib/verify-typescript-setup.js +3 -1
  164. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  165. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  166. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  167. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  168. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +17 -4
  169. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  170. package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/escape-path-delimiters.js +1 -1
  171. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  172. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  173. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  174. package/.next/standalone/node_modules/next/package.json +11 -11
  175. package/.next/standalone/package.json +14 -13
  176. package/.next/standalone/sdk/python/CHANGELOG.md +155 -0
  177. package/.next/standalone/sdk/python/README.md +8 -0
  178. package/.next/standalone/sdk/python/examples/evaluator_worker.py +121 -0
  179. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  180. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +101 -0
  181. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__main__.py +49 -0
  182. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/_sandbox_runner.py +63 -0
  183. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/authoring.py +404 -0
  184. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +299 -0
  185. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/protocol.py +754 -0
  186. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +936 -0
  187. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/source.py +684 -0
  188. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/README.md +28 -0
  189. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/contract.json +252 -0
  190. package/.next/standalone/sdk/python/tests/test_evaluator_authoring.py +124 -0
  191. package/.next/standalone/sdk/python/tests/test_evaluator_client.py +253 -0
  192. package/.next/standalone/sdk/python/tests/test_evaluator_example.py +35 -0
  193. package/.next/standalone/sdk/python/tests/test_evaluator_http_e2e.py +636 -0
  194. package/.next/standalone/sdk/python/tests/test_evaluator_main.py +47 -0
  195. package/.next/standalone/sdk/python/tests/test_evaluator_protocol.py +246 -0
  196. package/.next/standalone/sdk/python/tests/test_evaluator_review_fixes.py +164 -0
  197. package/.next/standalone/sdk/python/tests/test_evaluator_runtime.py +1103 -0
  198. package/.next/standalone/sdk/python/tests/test_evaluator_source.py +430 -0
  199. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +17 -0
  200. package/.next/standalone/sdk/python/uv.lock +29 -29
  201. package/.next/standalone/server.js +1 -1
  202. package/README.md +13 -58
  203. package/dist/cli.mjs +5368 -8756
  204. package/dist/worker.mjs +539 -312
  205. package/lib/auth/api-server-client.ts +28 -0
  206. package/lib/claude-sessions.ts +101 -27
  207. package/package.json +14 -13
  208. package/pi-extension/index.ts +42 -4
  209. package/scripts/changelog-open.py +115 -0
  210. package/src/audit/cli.ts +134 -0
  211. package/src/audit/desktop-notify.ts +420 -0
  212. package/src/audit/harm-report.ts +101 -0
  213. package/src/audit/index.ts +141 -13
  214. package/src/audit/leak-fingerprint.ts +200 -0
  215. package/src/audit/leak-notice.ts +161 -0
  216. package/src/audit/leak-record.ts +232 -0
  217. package/src/audit/leak-scan.ts +292 -0
  218. package/src/audit/leak-store.ts +217 -0
  219. package/src/audit/macos-notifier.ts +310 -0
  220. package/src/audit/redact-example.ts +266 -14
  221. package/src/audit/report-harm.ts +12 -1
  222. package/src/audit/report.ts +6 -3
  223. package/src/audit/schedule-cli.ts +37 -0
  224. package/src/audit/scoring.ts +49 -0
  225. package/src/audit/types.ts +41 -0
  226. package/src/hooks/builtin-policies.ts +67 -2
  227. package/src/hooks/configure-wizard.ts +20 -0
  228. package/src/hooks/fp-config.ts +71 -11
  229. package/src/hooks/fp-home.ts +27 -0
  230. package/src/hooks/handler.ts +59 -1
  231. package/src/hooks/integrations.ts +47 -13
  232. package/src/hooks/notice.ts +155 -0
  233. package/src/hooks/uninstall-cli.ts +15 -0
  234. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1b5jzxr._.js +0 -3
  235. package/.next/standalone/.next/server/chunks/ssr/_0j6gd_k._.js +0 -23
  236. package/.next/standalone/.next/server/chunks/ssr/_0v8eebd._.js +0 -3
  237. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +0 -3
  238. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +0 -3
  239. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_1ao30b1.js +0 -3
  240. package/.next/standalone/.next/static/chunks/03slu0c8_71y7.js +0 -1
  241. package/.next/standalone/.next/static/chunks/0nm11ysn0153h.js +0 -1
  242. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +0 -1
  243. package/.next/standalone/.next/static/chunks/1r9676mkvihto.js +0 -1
  244. package/.next/standalone/.next/static/chunks/30pct5s734jua.js +0 -6
  245. package/.next/standalone/.next/static/chunks/3pibxnbhjza7n.js +0 -69
  246. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +0 -1
  247. package/.next/standalone/.next/static/chunks/3zkg2s2vzxc3d.js +0 -1
  248. package/.next/standalone/.next/static/chunks/41b5ptaocvi_l.js +0 -1
  249. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_buildManifest.js +0 -0
  250. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_clientMiddlewareManifest.js +0 -0
  251. /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
+ )