agentenv-framework 0.9.1254__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. agent_env/__init__.py +0 -0
  2. agent_env/a2a_agent/__init__.py +23 -0
  3. agent_env/a2a_agent/a2a_agent.py +600 -0
  4. agent_env/a2a_agent/conversation_store.py +356 -0
  5. agent_env/a2a_agent/object_transfer.py +609 -0
  6. agent_env/a2a_agent/protocol.py +238 -0
  7. agent_env/a2a_agent/store.py +201 -0
  8. agent_env/a2a_agent/validator.py +901 -0
  9. agent_env/artifact/__init__.py +48 -0
  10. agent_env/artifact/artifact.py +74 -0
  11. agent_env/artifact/artifacts/__init__.py +0 -0
  12. agent_env/artifact/artifacts/cli.py +83 -0
  13. agent_env/artifact/artifacts/docker_image.py +457 -0
  14. agent_env/artifact/artifacts/environment.py +89 -0
  15. agent_env/artifact/artifacts/environment_universe.py +156 -0
  16. agent_env/artifact/artifacts/file.py +214 -0
  17. agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
  18. agent_env/artifact/artifacts/skill.py +206 -0
  19. agent_env/artifact/artifacts/vm_image.py +95 -0
  20. agent_env/artifact/ref.py +15 -0
  21. agent_env/artifact/registry.py +251 -0
  22. agent_env/artifact/store.py +295 -0
  23. agent_env/artifact/universe.py +38 -0
  24. agent_env/attribution.py +41 -0
  25. agent_env/bundle/__init__.py +4 -0
  26. agent_env/bundle/_fs.py +128 -0
  27. agent_env/bundle/authoring.py +239 -0
  28. agent_env/bundle/installed.py +175 -0
  29. agent_env/bundle/ledger.py +269 -0
  30. agent_env/bundle/materialize.py +204 -0
  31. agent_env/bundle/parse.py +594 -0
  32. agent_env/bundle/plan.py +414 -0
  33. agent_env/bundle/resolve.py +404 -0
  34. agent_env/bundle/run.py +361 -0
  35. agent_env/cli/__init__.py +69 -0
  36. agent_env/cli/__main__.py +10 -0
  37. agent_env/cli/_installers.py +576 -0
  38. agent_env/cli/_plugin_changes.py +947 -0
  39. agent_env/cli/a2a_agent/__init__.py +22 -0
  40. agent_env/cli/a2a_agent/add_skill.py +97 -0
  41. agent_env/cli/a2a_agent/deploy.py +60 -0
  42. agent_env/cli/a2a_agent/get.py +15 -0
  43. agent_env/cli/a2a_agent/get_instance.py +26 -0
  44. agent_env/cli/a2a_agent/put.py +78 -0
  45. agent_env/cli/a2a_agent/validate.py +56 -0
  46. agent_env/cli/artifact/__init__.py +20 -0
  47. agent_env/cli/artifact/cli.py +83 -0
  48. agent_env/cli/artifact/environment.py +44 -0
  49. agent_env/cli/artifact/environment_universe.py +163 -0
  50. agent_env/cli/artifact/file_artifact_universe.py +281 -0
  51. agent_env/cli/artifact/skill.py +128 -0
  52. agent_env/cli/banner.py +25 -0
  53. agent_env/cli/config.py +263 -0
  54. agent_env/cli/env/__init__.py +31 -0
  55. agent_env/cli/env/deploy.py +112 -0
  56. agent_env/cli/env/gateway.py +57 -0
  57. agent_env/cli/env/get_instance.py +40 -0
  58. agent_env/cli/env/mcp_server.py +410 -0
  59. agent_env/cli/env/multi.py +282 -0
  60. agent_env/cli/env/service_db.py +99 -0
  61. agent_env/cli/env/snapshot.py +35 -0
  62. agent_env/cli/env/state/__init__.py +6 -0
  63. agent_env/cli/env/state/init.py +108 -0
  64. agent_env/cli/env/state/teardown.py +44 -0
  65. agent_env/cli/env/website.py +216 -0
  66. agent_env/cli/env/website_browser.py +67 -0
  67. agent_env/cli/eval/__init__.py +16 -0
  68. agent_env/cli/eval/add_tasks.py +63 -0
  69. agent_env/cli/eval/create.py +49 -0
  70. agent_env/cli/eval/run.py +194 -0
  71. agent_env/cli/identity/__init__.py +5 -0
  72. agent_env/cli/identity/client_identity.py +37 -0
  73. agent_env/cli/plugin.py +438 -0
  74. agent_env/cli/run.py +285 -0
  75. agent_env/cli/task/__init__.py +21 -0
  76. agent_env/cli/task/create.py +109 -0
  77. agent_env/cli/task/get.py +17 -0
  78. agent_env/cli/task/get_instance.py +34 -0
  79. agent_env/cli/task/run.py +615 -0
  80. agent_env/cli/task/validate.py +31 -0
  81. agent_env/cli/up.py +123 -0
  82. agent_env/cli/utils.py +183 -0
  83. agent_env/config/__init__.py +50 -0
  84. agent_env/config/describe.py +1067 -0
  85. agent_env/config/errors.py +13 -0
  86. agent_env/config/loader.py +245 -0
  87. agent_env/config/model.py +154 -0
  88. agent_env/config/paths.py +25 -0
  89. agent_env/config/plugin_tables.py +101 -0
  90. agent_env/config/provenance.py +134 -0
  91. agent_env/config/runtime.py +1064 -0
  92. agent_env/config/snapshot.py +81 -0
  93. agent_env/entity_refs.py +186 -0
  94. agent_env/env/__init__.py +49 -0
  95. agent_env/env/env.py +433 -0
  96. agent_env/env/env_artifact_store.py +87 -0
  97. agent_env/env/envs/__init__.py +7 -0
  98. agent_env/env/envs/_deployment.py +281 -0
  99. agent_env/env/envs/gateway_server.py +32 -0
  100. agent_env/env/envs/mcp_server.py +387 -0
  101. agent_env/env/envs/multi_env.py +780 -0
  102. agent_env/env/envs/service_db/Dockerfile +20 -0
  103. agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
  104. agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
  105. agent_env/env/envs/service_db.py +92 -0
  106. agent_env/env/envs/website.py +294 -0
  107. agent_env/env/envs/website_browser/Dockerfile +40 -0
  108. agent_env/env/envs/website_browser/__init__.py +17 -0
  109. agent_env/env/envs/website_browser/entrypoint.sh +13 -0
  110. agent_env/env/gateway/Dockerfile +14 -0
  111. agent_env/env/gateway/__init__.py +37 -0
  112. agent_env/env/gateway/clock.py +184 -0
  113. agent_env/env/gateway/constants.py +190 -0
  114. agent_env/env/gateway/entrypoint.py +156 -0
  115. agent_env/env/gateway/gateway.py +1095 -0
  116. agent_env/env/gateway/get_time.py +60 -0
  117. agent_env/env/gateway/requirements.txt +4 -0
  118. agent_env/env/gateway/triggers.py +1193 -0
  119. agent_env/env/legacy_protocol.py +146 -0
  120. agent_env/env/registry.py +93 -0
  121. agent_env/env/snapshot_store.py +372 -0
  122. agent_env/env/store.py +411 -0
  123. agent_env/eval/__init__.py +14 -0
  124. agent_env/eval/eval.py +68 -0
  125. agent_env/eval/store.py +116 -0
  126. agent_env/examples/hello/README.md +9 -0
  127. agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
  128. agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
  129. agent_env/examples/hello/tasks/hello.json +10 -0
  130. agent_env/explorer/__init__.py +12 -0
  131. agent_env/explorer/app.py +277 -0
  132. agent_env/explorer/openapi_docs.py +242 -0
  133. agent_env/explorer/plugin.py +95 -0
  134. agent_env/explorer/routers/__init__.py +1 -0
  135. agent_env/explorer/routers/common.py +174 -0
  136. agent_env/explorer/routers/conversations.py +25 -0
  137. agent_env/explorer/routers/objects.py +112 -0
  138. agent_env/explorer/routers/runs.py +475 -0
  139. agent_env/plugins/__init__.py +59 -0
  140. agent_env/plugins/_cli.py +244 -0
  141. agent_env/plugins/_discovery.py +97 -0
  142. agent_env/plugins/_inventory.py +262 -0
  143. agent_env/plugins/_registration.py +268 -0
  144. agent_env/plugins/_report.py +47 -0
  145. agent_env/plugins/_requirements.py +55 -0
  146. agent_env/providers/__init__.py +37 -0
  147. agent_env/providers/env_providers/__init__.py +12 -0
  148. agent_env/providers/env_providers/constants.py +13 -0
  149. agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
  150. agent_env/providers/env_providers/env_provider.py +237 -0
  151. agent_env/providers/env_providers/env_server_provider.py +133 -0
  152. agent_env/providers/env_state/__init__.py +49 -0
  153. agent_env/providers/env_state/env_state_provider.py +554 -0
  154. agent_env/providers/env_state/local_postgres.py +390 -0
  155. agent_env/providers/env_state/store.py +112 -0
  156. agent_env/providers/sandbox_providers/__init__.py +29 -0
  157. agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
  158. agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
  159. agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
  160. agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
  161. agent_env/providers/sandbox_providers/e2b/template.py +158 -0
  162. agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
  163. agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
  164. agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
  165. agent_env/providers/sandbox_providers/sandbox.py +387 -0
  166. agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
  167. agent_env/py.typed +0 -0
  168. agent_env/runner/__init__.py +16 -0
  169. agent_env/runner/local_runner.py +190 -0
  170. agent_env/runner/runner.py +127 -0
  171. agent_env/runner/store.py +94 -0
  172. agent_env/store/__init__.py +126 -0
  173. agent_env/store/_google.py +68 -0
  174. agent_env/store/base.py +33 -0
  175. agent_env/store/document_store/__init__.py +60 -0
  176. agent_env/store/document_store/document_store.py +519 -0
  177. agent_env/store/document_store/dynamodb_document_store.py +306 -0
  178. agent_env/store/document_store/evaluation.py +199 -0
  179. agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
  180. agent_env/store/document_store/mongo_document_store.py +259 -0
  181. agent_env/store/document_store/sqlite_document_store.py +314 -0
  182. agent_env/store/ids.py +123 -0
  183. agent_env/store/image_store/__init__.py +34 -0
  184. agent_env/store/image_store/ecr_image_store.py +131 -0
  185. agent_env/store/image_store/google_credentials.py +113 -0
  186. agent_env/store/image_store/image_store.py +113 -0
  187. agent_env/store/image_store/local_registry_image_store.py +106 -0
  188. agent_env/store/image_store/oci_registry_credentials.py +196 -0
  189. agent_env/store/local_state.py +27 -0
  190. agent_env/store/object_store/__init__.py +24 -0
  191. agent_env/store/object_store/gcs_object_store.py +415 -0
  192. agent_env/store/object_store/local_object_store.py +143 -0
  193. agent_env/store/object_store/object_store.py +198 -0
  194. agent_env/store/object_store/s3_object_store.py +354 -0
  195. agent_env/store/query.py +112 -0
  196. agent_env/store/routing.py +609 -0
  197. agent_env/store/secret_store/__init__.py +19 -0
  198. agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
  199. agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
  200. agent_env/store/secret_store/local_secret_store.py +47 -0
  201. agent_env/store/secret_store/secret_store.py +19 -0
  202. agent_env/task/__init__.py +27 -0
  203. agent_env/task/interrupts.py +109 -0
  204. agent_env/task/registry.py +27 -0
  205. agent_env/task/step_journal.py +99 -0
  206. agent_env/task/store.py +940 -0
  207. agent_env/task/task.py +815 -0
  208. agent_env/task/teardown.py +142 -0
  209. agent_env/task_step/__init__.py +47 -0
  210. agent_env/task_step/context.py +195 -0
  211. agent_env/task_step/context_ops.py +224 -0
  212. agent_env/task_step/registry.py +182 -0
  213. agent_env/task_step/review_store.py +118 -0
  214. agent_env/task_step/snapshot_utils/__init__.py +2 -0
  215. agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
  216. agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
  217. agent_env/task_step/store.py +129 -0
  218. agent_env/task_step/task_step.py +173 -0
  219. agent_env/task_step/task_steps/__init__.py +27 -0
  220. agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
  221. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
  222. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
  223. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
  224. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
  225. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
  226. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
  227. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
  228. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
  229. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
  230. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
  231. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
  232. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
  233. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
  234. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
  235. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
  236. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
  237. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
  238. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
  239. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
  240. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
  241. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
  242. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
  243. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
  244. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
  245. agent_env/task_step/task_steps/add_skills.py +240 -0
  246. agent_env/task_step/task_steps/apply_server_config.py +288 -0
  247. agent_env/task_step/task_steps/collect_artifacts.py +940 -0
  248. agent_env/task_step/task_steps/deploy_agent.py +626 -0
  249. agent_env/task_step/task_steps/deploy_env.py +197 -0
  250. agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
  251. agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
  252. agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
  253. agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
  254. agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
  255. agent_env/task_step/task_steps/install_agent.py +376 -0
  256. agent_env/task_step/task_steps/load_artifact.py +775 -0
  257. agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
  258. agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
  259. agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
  260. agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
  261. agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
  262. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
  263. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
  264. agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
  265. agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
  266. agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
  267. agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
  268. agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
  269. agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
  270. agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
  271. agent_env/task_step/task_steps/peer_agents.py +92 -0
  272. agent_env/task_step/task_steps/prompt_agent.py +900 -0
  273. agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
  274. agent_env/task_step/task_steps/register_env_triggers.py +97 -0
  275. agent_env/task_step/task_steps/reset_env.py +71 -0
  276. agent_env/task_step/task_steps/review.py +152 -0
  277. agent_env/task_step/task_steps/run_code.py +461 -0
  278. agent_env/task_step/task_steps/run_code_runner.py +52 -0
  279. agent_env/task_step/task_steps/run_docker_container.py +380 -0
  280. agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
  281. agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
  282. agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
  283. agent_env/task_step/task_steps/snapshot_env.py +637 -0
  284. agent_env/task_step/task_steps/sync_env_clock.py +132 -0
  285. agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
  286. agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
  287. agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
  288. agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
  289. agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
  290. agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
  291. agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
  292. agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
  293. agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
  294. agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
  295. agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
  296. agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
  297. agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
  298. agent_env/task_step/thread_work.py +61 -0
  299. agent_env/utils/__init__.py +0 -0
  300. agent_env/utils/card_naming.py +108 -0
  301. agent_env/utils/deprecation.py +109 -0
  302. agent_env/utils/docker_build.py +29 -0
  303. agent_env/utils/exec_retry.py +77 -0
  304. agent_env/utils/litellm_attribution.py +59 -0
  305. agent_env/utils/paths.py +20 -0
  306. agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
  307. agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
  308. agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
  309. agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
  310. agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
  311. agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
  312. agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
agent_env/task/task.py ADDED
@@ -0,0 +1,815 @@
1
+ """Base Task model for AgentEnv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import copy
7
+ import dataclasses
8
+ import inspect
9
+ import itertools
10
+ import json
11
+ import logging
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from typing import TYPE_CHECKING, Callable, ClassVar, Optional
17
+
18
+ from agent_env.plugins import _registration
19
+ from agent_env.store.routing import run_scope
20
+ from agent_env.task_step.context import TaskStepContext, regraft_redacted_keys
21
+ from agent_env.task_step.context_ops import ContextUpdateOps, build_context_update_ops
22
+ from agent_env.task_step.task_step import RetryConfig, TaskStep
23
+
24
+ if TYPE_CHECKING:
25
+ from agent_env.task.store import TaskInstance, TaskQuery
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _identity(entry: dict) -> str:
32
+ return json.dumps(entry, sort_keys=True, default=str)
33
+
34
+
35
+ def _rolled_back_sandboxes(before: dict, after: dict) -> dict:
36
+ """The sandbox coordinates of every deployed env / agent / sandbox present in the stored
37
+ context ``before`` an undo and absent ``after`` it, shaped like the context's own slices
38
+ so the executor's cleanup can scan the marker exactly as it scans a context. The undo is
39
+ what removes these from the live and persisted context, so without this record an
40
+ orphaned sandbox from the failed attempt is discoverable nowhere.
41
+ """
42
+ def gone(key: str) -> list[dict]:
43
+ kept = {_identity(e) for e in after.get(key) or []}
44
+ return [e for e in before.get(key) or [] if _identity(e) not in kept]
45
+
46
+ return {
47
+ "deployed_envs": [
48
+ {"sandbox_id": e.get("sandbox_id"), "sandbox_type": e.get("sandbox_type"),
49
+ "sandbox_ids": e.get("sandbox_ids") or {}}
50
+ for e in gone("deployed_envs")
51
+ ],
52
+ "deployed_agents": [
53
+ {"sandbox_id": a.get("sandbox_id"), "sandbox_type": a.get("sandbox_type")}
54
+ for a in gone("deployed_agents") if a.get("sandbox_id")
55
+ ],
56
+ "deployed_sandboxes": [
57
+ {"sandbox_id": x.get("sandbox_id"), "sandbox_type": x.get("sandbox_type")}
58
+ for x in gone("deployed_sandboxes")
59
+ ],
60
+ }
61
+
62
+
63
+ def _rebuild_context(context: TaskStepContext, stored: dict) -> None:
64
+ """Replace ``context``'s fields in place from the context the store rebuilt. In place,
65
+ because the scheduler and every step hold this object. The stored document never holds
66
+ secrets (``_REDACTED_KEYS``), so they are grafted back from the live context first."""
67
+ stored = copy.deepcopy(stored)
68
+ regraft_redacted_keys({"metadata": context.metadata}, stored)
69
+ rebuilt = TaskStepContext.from_dict(stored)
70
+ for f in dataclasses.fields(TaskStepContext):
71
+ setattr(context, f.name, getattr(rebuilt, f.name))
72
+
73
+
74
+ def _overlapping_survivors(
75
+ rollback_ids: set[str], completed_ids: set[str], windows: dict[str, tuple[int, int]],
76
+ ) -> list[str]:
77
+ """Completed steps outside the span whose execution window overlapped a span member's.
78
+
79
+ Until per-step isolation lands, a step's journaled diff is taken against the
80
+ shared in-memory context, so a step that ran while a span member ran may have journaled
81
+ the member's writes; replaying it would rebuild the span's world dirty. Windows are
82
+ (dispatch tick, end tick); a step never dispatched has none and cannot overlap.
83
+ """
84
+ members = [windows[x] for x in rollback_ids if x in windows]
85
+ return sorted(
86
+ s for s in completed_ids - rollback_ids
87
+ if s in windows and any(windows[s][0] < end and start < windows[s][1] for start, end in members)
88
+ )
89
+
90
+
91
+ def substitute_seed_in_steps(steps: list["TaskStep"], seed: dict) -> list["TaskStep"]:
92
+ """Round-trip each step through its own to_dict/from_dict with ``<key>``
93
+ seed placeholders replaced in every string leaf. Returns a NEW list, never
94
+ mutating the input; idempotent (an already-substituted string has no tokens
95
+ left). Used by Task.run(); a retry re-dispatches the same substituted step
96
+ objects, so it executes exactly what the original run executed."""
97
+ if not seed:
98
+ return list(steps)
99
+ from agent_env.task_step.registry import get_task_step_registry
100
+
101
+ def _sub(value):
102
+ if isinstance(value, str):
103
+ for k, v in seed.items():
104
+ value = value.replace(f"<{k}>", str(v))
105
+ return value
106
+ if isinstance(value, list):
107
+ return [_sub(x) for x in value]
108
+ if isinstance(value, dict):
109
+ return {k: _sub(v) for k, v in value.items()}
110
+ return value
111
+
112
+ from agent_env.task_step.task_step import attach_retry_config
113
+
114
+ registry = get_task_step_registry()
115
+ out: list["TaskStep"] = []
116
+ for step in steps:
117
+ data = _sub(step.to_dict())
118
+ out.append(attach_retry_config(registry[step.type].from_dict(data), data))
119
+ return out
120
+
121
+
122
+ def _dependency_ids(steps: list["TaskStep"]) -> dict[str, set[str]]:
123
+ """Direct dependency ids per step; depends_on None = all prior (scheduler semantics)."""
124
+ deps: dict[str, set[str]] = {}
125
+ for i, step in enumerate(steps):
126
+ if step.depends_on is None:
127
+ deps[step.id] = {prior.id for prior in steps[:i]}
128
+ else:
129
+ deps[step.id] = {d.task_step_id for d in step.depends_on}
130
+ return deps
131
+
132
+
133
+ def _ancestor_ids(step_id: str, deps: dict[str, set[str]]) -> set[str]:
134
+ """Transitive dependency ancestry of ``step_id`` under ``deps``."""
135
+ seen: set[str] = set()
136
+ frontier = list(deps.get(step_id, ()))
137
+ while frontier:
138
+ current = frontier.pop()
139
+ if current in seen:
140
+ continue
141
+ seen.add(current)
142
+ frontier.extend(deps.get(current, ()))
143
+ return seen
144
+
145
+
146
+ def _dependents_closure(root: str, dependents: dict[str, list[str]]) -> set[str]:
147
+ """``root`` plus every step that transitively depends on it.
148
+
149
+ The rollback span: rebuilding the world from ``root`` invalidates everything
150
+ built on top of it, not just the failed step's own ancestry. A step that
151
+ depends on a span member but is not an ancestor of the failed step would
152
+ otherwise be left in ``completed`` while its inputs are torn down, so the
153
+ retry would run against a half-built world.
154
+ """
155
+ seen: set[str] = {root}
156
+ frontier = [root]
157
+ while frontier:
158
+ current = frontier.pop()
159
+ for nxt in dependents.get(current, ()):
160
+ if nxt not in seen:
161
+ seen.add(nxt)
162
+ frontier.append(nxt)
163
+ return seen
164
+
165
+
166
+ async def _roll_back_span(
167
+ context: TaskStepContext,
168
+ instance_id: str | None,
169
+ steps: list["TaskStep"],
170
+ dependents: dict[str, list[str]],
171
+ failed_id: str,
172
+ resume_from: str,
173
+ cancelled_ids: set[str],
174
+ attempt: int,
175
+ windows: dict[str, tuple[int, int]],
176
+ completed_ids: set[str],
177
+ log_prefix: str,
178
+ ) -> tuple[set[str], int] | None:
179
+ """Roll back a failed span so the scheduler can re-dispatch it on a clean world.
180
+
181
+ The span is ``resume_from`` plus everything that transitively depends on it (the world is
182
+ rebuilt from there, so every step built on top re-runs; the failed step is in that closure
183
+ by ``_validate_dag``'s ancestry check), plus any sibling cancelled mid-execute (it may
184
+ hold partial writes). Returns the rolled-back step ids and the epoch to dispatch under, or
185
+ None to abort: the task then fails with the original error and the live context untouched.
186
+
187
+ The rollback is the store's ``undo_steps``: it replays the surviving steps' journaled
188
+ diffs over the run's seed in one CAS, so everything the span wrote goes, whatever it was
189
+ (typed entries, custom metadata, an overwritten scalar), with no per-producer bookkeeping.
190
+ The scheduler's own record rides in the same CAS: the recovered failures flagged
191
+ ``retried`` (kept in ``failed_steps`` as audit, but a consumer reading that list must not
192
+ report a recovered run as failed) and a ``retry_resets`` marker naming the span and the
193
+ sandboxes the undo stripped, for the executor's cleanup to reclaim. The live context is
194
+ then rebuilt from the returned document.
195
+
196
+ Abort cases: no task-instance record (nothing to undo through); the store refuses (a run
197
+ that predates the journal, or a surviving step with no entry); the write fails; or a
198
+ surviving step ran concurrently with a span member, whose diff may carry the span's writes
199
+ (``_overlapping_survivors``). The write is shielded from cancellation and, once landed,
200
+ the live context is always brought in line with it.
201
+ """
202
+ from agent_env.task import store as _store
203
+
204
+ span_ids = _dependents_closure(resume_from, dependents)
205
+ span_order = [s.id for s in steps if s.id in span_ids]
206
+ rollback_ids = span_ids | cancelled_ids
207
+ logger.warning(
208
+ "%sStep %s failed; rolling back span %s and re-dispatching from %s (attempt %d).",
209
+ log_prefix, failed_id, span_order, resume_from, attempt,
210
+ )
211
+ if not instance_id:
212
+ logger.error(
213
+ "%sCannot retry step %s: this run has no task-instance record to roll back "
214
+ "through; failing with the original error.", log_prefix, failed_id,
215
+ )
216
+ return None
217
+ overlapping = _overlapping_survivors(rollback_ids, completed_ids, windows)
218
+ if overlapping:
219
+ logger.error(
220
+ "%sCannot retry step %s: steps %s completed while the span was executing, so "
221
+ "their journaled diffs may include the span's writes (exact per-step diffs need "
222
+ "per-step isolation, which is not yet implemented); failing with the original error.",
223
+ log_prefix, failed_id, overlapping,
224
+ )
225
+ return None
226
+
227
+ failed_steps = copy.deepcopy(context.metadata.get("failed_steps", []))
228
+ for f in failed_steps:
229
+ # A caller-seeded context may carry a legacy non-dict entry; leave it alone.
230
+ if isinstance(f, dict) and f.get("step_id") in rollback_ids:
231
+ f["retried"] = True
232
+ resets = copy.deepcopy(context.metadata.get("retry_resets", []))
233
+ marker = {
234
+ "attempt": attempt,
235
+ "step_id": failed_id,
236
+ "retry_from_step_id": resume_from,
237
+ "replayed": span_order,
238
+ "at_utc": datetime.now(timezone.utc).isoformat(),
239
+ }
240
+
241
+ def _audit(before: dict, after: dict) -> ContextUpdateOps:
242
+ # Runs inside the store's CAS with the context as stored and as replayed.
243
+ entry = {**marker, "rolled_back_sandboxes": _rolled_back_sandboxes(before, after)}
244
+ return ContextUpdateOps(sets={
245
+ "context.metadata.failed_steps": failed_steps,
246
+ "context.metadata.retry_resets": resets + [entry],
247
+ })
248
+
249
+ persist = asyncio.ensure_future(
250
+ _store.undo_steps(instance_id, rollback_ids, extra_ops=_audit, bump_epoch=True)
251
+ )
252
+ try:
253
+ doc = await asyncio.shield(persist)
254
+ except asyncio.CancelledError:
255
+ # The write runs in a thread and lands regardless; wait for it so the live
256
+ # context matches the stored record before the cancellation unwinds.
257
+ landed = (await asyncio.gather(persist, return_exceptions=True))[0]
258
+ if isinstance(landed, dict):
259
+ _rebuild_context(context, landed["context"])
260
+ raise
261
+ except ValueError as refusal:
262
+ logger.error(
263
+ "%sCannot retry step %s: %s; failing with the original error.",
264
+ log_prefix, failed_id, refusal,
265
+ )
266
+ return None
267
+ if doc is None:
268
+ logger.error(
269
+ "%sSpan rollback for step %s could not be persisted (instance_id=%s); "
270
+ "failing the task instead of retrying on an inconsistent record.",
271
+ log_prefix, failed_id, instance_id,
272
+ )
273
+ return None
274
+ _rebuild_context(context, doc["context"])
275
+ return rollback_ids, doc.get("attempt_epoch", 0)
276
+
277
+
278
+ @dataclass
279
+ class _SchedulerState:
280
+ """Initial state the DAG scheduler needs to drive execution.
281
+
282
+ - `dependents[step_id]`: steps released when `step_id` completes.
283
+ - `pending[step_id]`: count of deps that still need to complete before `step_id` runs.
284
+ - `completed`: seeded with steps skipped by `start_step`.
285
+ - `ready`: steps whose deps are all satisfied and can launch immediately.
286
+ """
287
+ step_by_id: dict[str, "TaskStep"]
288
+ dependents: dict[str, list[str]]
289
+ pending: dict[str, int]
290
+ completed: set[str]
291
+ ready: list["TaskStep"]
292
+
293
+
294
+ class Task:
295
+ """A task is an ordered sequence of task steps.
296
+
297
+ Steps are stored inline and executed by run() according to the DAG
298
+ encoded in each step's depends_on. Independent steps run concurrently.
299
+ """
300
+
301
+ type: ClassVar[str] = "task"
302
+
303
+ def __init__(
304
+ self,
305
+ id: str,
306
+ version: Optional[int],
307
+ steps: list[TaskStep] | None = None,
308
+ project_id: Optional[str] = None,
309
+ ):
310
+ self.id = id
311
+ self.version = version
312
+ self.steps: list[TaskStep] = steps or []
313
+ self.project_id = project_id
314
+ self._validate_dag(self.steps)
315
+
316
+ @classmethod
317
+ def _validate_dag(cls, steps: list[TaskStep]) -> None:
318
+ seen: set[str] = set()
319
+ for i, step in enumerate(steps):
320
+ if step.id in seen:
321
+ raise ValueError(
322
+ f"Duplicate step id '{step.id}' at position {i}; step ids must be unique within a task"
323
+ )
324
+ seen.add(step.id)
325
+ prior = {s.id for s in steps[:i]}
326
+ retry_config = getattr(step, "retry_config", None)
327
+ if retry_config is not None:
328
+ # Trust the task author's resume point, but catch an obviously
329
+ # invalid one at creation: it must be the step itself (re-run just
330
+ # this step) or an earlier dependency ancestor. We do not judge
331
+ # which spanned steps are replay-safe.
332
+ ancestors = _ancestor_ids(step.id, _dependency_ids(steps[: i + 1]))
333
+ if (
334
+ retry_config.retry_from_step_id != step.id
335
+ and retry_config.retry_from_step_id not in ancestors
336
+ ):
337
+ raise ValueError(
338
+ f"Step '{step.id}' at position {i} declares retry_config.retry_from_step_id "
339
+ f"'{retry_config.retry_from_step_id}' which is not in the step's dependency "
340
+ f"ancestry (ancestor ids: {sorted(ancestors)})"
341
+ )
342
+ if step.depends_on is None:
343
+ continue
344
+ for dep in step.depends_on:
345
+ if dep.task_step_id not in prior:
346
+ raise ValueError(
347
+ f"Step '{step.id}' at position {i} declares depends_on "
348
+ f"'{dep.task_step_id}' which does not appear in a prior step "
349
+ f"(forward refs are not allowed; known prior ids: {sorted(prior)})"
350
+ )
351
+
352
+ def preflight(self) -> list[str]:
353
+ """Every step's resolvable-config problems, in step order; empty when fine.
354
+
355
+ Covers what needs the stores to answer; `_validate_dag` already covers ids and edges.
356
+ """
357
+ problems: list[str] = []
358
+ for step in self.steps:
359
+ problems.extend(step.preflight())
360
+ return problems
361
+
362
+ def to_dict(self) -> dict:
363
+ doc: dict = {
364
+ "id": self.id,
365
+ "type": self.type,
366
+ "version": self.version,
367
+ "steps": [step.to_dict() for step in self.steps],
368
+ }
369
+ if self.project_id is not None:
370
+ doc["project_id"] = self.project_id
371
+ return doc
372
+
373
+ @classmethod
374
+ def from_dict(cls, data: dict) -> "Task":
375
+ from agent_env.task_step.registry import get_task_step_registry
376
+
377
+ registry = get_task_step_registry()
378
+ steps = []
379
+ for step_dict in data.get("steps", []):
380
+ step_type = step_dict["type"]
381
+ step_cls = registry.get(step_type)
382
+ if step_cls is None:
383
+ raise ValueError(
384
+ f"Unknown task step type: {step_type}{_registration.failure_note(_registration.TASK_STEPS, step_type)}"
385
+ )
386
+ from agent_env.task_step.task_step import attach_retry_config
387
+
388
+ steps.append(attach_retry_config(step_cls.from_dict(step_dict), step_dict))
389
+ return cls(
390
+ id=data["id"],
391
+ version=data.get("version"),
392
+ steps=steps,
393
+ project_id=data.get("project_id"),
394
+ )
395
+
396
+ async def run(
397
+ self,
398
+ on_step_start: Callable[[int, int, TaskStep, TaskStepContext], None] | None = None,
399
+ on_step_complete: Callable[[int, int, TaskStep, TaskStepContext, float], None] | None = None,
400
+ agent_model: str | None = None,
401
+ agent_artifact_id: str | None = None,
402
+ start_step: int = 0,
403
+ end_step: int | None = None,
404
+ context: TaskStepContext | None = None,
405
+ instance_id: str | None = None,
406
+ ) -> TaskStepContext:
407
+ """Run steps per their depends_on DAG. Per-step `fail_task_on_error`
408
+ (default True) controls halt-on-failure; failures land in
409
+ context.metadata['failed_steps']. The run is scoped to this task's id, so under namespace
410
+ routing an ``@local`` task's records stay in the local stores.
411
+ """
412
+ with run_scope(self.id):
413
+ return await self._run(
414
+ on_step_start, on_step_complete, agent_model, agent_artifact_id,
415
+ start_step, end_step, context, instance_id,
416
+ )
417
+
418
+ async def _run(
419
+ self,
420
+ on_step_start: Callable[[int, int, TaskStep, TaskStepContext], None] | None,
421
+ on_step_complete: Callable[[int, int, TaskStep, TaskStepContext, float], None] | None,
422
+ agent_model: str | None,
423
+ agent_artifact_id: str | None,
424
+ start_step: int,
425
+ end_step: int | None,
426
+ context: TaskStepContext | None,
427
+ instance_id: str | None,
428
+ ) -> TaskStepContext:
429
+ from .store import (
430
+ TaskStepResult,
431
+ TaskStepStatus,
432
+ register_task_instance,
433
+ seed_task_instance_context,
434
+ )
435
+
436
+ if context is None:
437
+ context = TaskStepContext()
438
+ context.metadata.setdefault("run_group_id", uuid.uuid4().hex)
439
+
440
+ # Seed substitution for EVERY step field, not just prompts. The hub's
441
+ # "Seed values" box writes context.metadata["seed"], and until now only
442
+ # PromptAgentTaskStep._apply_seed consumed it — so a task could
443
+ # parameterize what an LLM reads but not which env it deploys or which
444
+ # artifact it loads. A `<multienv_id>` placeholder in deploy_env.env_id
445
+ # was simply never substituted, and the step tried to deploy the literal
446
+ # string. Applied by round-tripping each step through its own
447
+ # to_dict/from_dict with `<key>` replaced in every string leaf: the
448
+ # steps re-validate themselves, and prompt-level substitution keeps
449
+ # working unchanged (a substituted prompt has no tokens left to apply).
450
+ #
451
+ # Into a LOCAL list, never self.steps: a Task object reused across runs
452
+ # (--k batches) must not carry run 1's substituted values into run 2,
453
+ # and concurrent runs must not race on a shared rewrite. And applied on
454
+ # EVERY start_step, not only 0: a resume reloads the task with its
455
+ # placeholders intact, so skipping substitution would hand later steps
456
+ # literal `<multienv_id>` strings. Substitution is idempotent — an
457
+ # already-replaced string has no tokens left.
458
+ seed = context.metadata.get("seed") or {}
459
+ steps = substitute_seed_in_steps(self.steps, seed) if seed else self.steps
460
+ if start_step < 0 or start_step >= len(steps):
461
+ raise ValueError(f"start_step={start_step} out of range [0, {len(steps)})")
462
+ if end_step is not None and (end_step <= start_step or end_step > len(steps)):
463
+ raise ValueError(f"end_step={end_step} out of range ({start_step}, {len(steps)}]")
464
+ if agent_model is not None:
465
+ context.agent_model = agent_model
466
+ if agent_artifact_id is not None:
467
+ context.agent_artifact_id = agent_artifact_id
468
+
469
+ # Pre-format a bracketed prefix for pass@k-correlated log lines.
470
+ # agent-env's log pipeline doesn't index ad-hoc `key=value` fields, so
471
+ # we surface the id as a leading tag (mirroring the `[uid]` /
472
+ # `[passAtK]` convention used upstream in the hub backend's pass@k workflows).
473
+ # Empty string when not running under pass@k so non-pass@k callers
474
+ # (playground, eval) don't get a noisy `[None]` prefix.
475
+ pass_at_k_execution_id = context.metadata.get("pass_at_k_execution_id")
476
+ log_prefix = f"[{pass_at_k_execution_id}] " if pass_at_k_execution_id else ""
477
+
478
+ total = len(steps)
479
+ completed_steps = [
480
+ TaskStepResult(step_id=step.id, status=TaskStepStatus.SUCCESS)
481
+ for step in steps[:start_step]
482
+ ]
483
+ instance = register_task_instance(
484
+ self.id,
485
+ self.version,
486
+ total,
487
+ start_step,
488
+ instance_id=instance_id,
489
+ completed_steps=completed_steps,
490
+ )
491
+ instance_id = instance.instance_id if instance else None
492
+ context.instance_id = instance_id
493
+ if instance_id:
494
+ seed_task_instance_context(instance_id, context)
495
+
496
+ state = self._build_scheduler_state(steps, start_step, end_step)
497
+ # _drive_dag drains in-flight steps and records the failure itself if it raises.
498
+ first_error, first_error_step_id = await self._drive_dag(
499
+ steps, state, total, context, instance_id,
500
+ on_step_start, on_step_complete, log_prefix,
501
+ )
502
+ if first_error is not None:
503
+ await self._handle_failure(first_error_step_id, first_error, context, instance_id)
504
+ return context
505
+
506
+ async def _drive_dag(
507
+ self,
508
+ steps: list[TaskStep],
509
+ state: "_SchedulerState",
510
+ total: int,
511
+ context: TaskStepContext,
512
+ instance_id: str | None,
513
+ on_step_start,
514
+ on_step_complete,
515
+ log_prefix: str,
516
+ ) -> tuple[BaseException | None, str | None]:
517
+ """The DAG scheduler, with in-loop rollback-and-re-dispatch retry.
518
+
519
+ Launches ready steps per their depends_on, records each on the instance,
520
+ and returns ``(first_error, first_error_step_id)``. Cancels + drains
521
+ in-flight steps on cancellation/error before re-raising.
522
+
523
+ When a step that declares ``retry_config`` fails with retries left
524
+ (whether or not it is tolerant), the scheduler drains in-flight work,
525
+ rolls back the failed span through the store's step journal
526
+ (``_roll_back_span`` -> ``undo_steps``), rebuilds the live context from
527
+ the result, and re-dispatches the span as ordinary steps. Attempt 1 and a
528
+ retry run the same guard-free path; the only difference is the
529
+ ``attempt_epoch`` a completion write carries, which fences a straggler
530
+ from a rolled-back attempt.
531
+ """
532
+ from agent_env.task import store as _store
533
+
534
+ index_of = {s.id: i for i, s in enumerate(steps)}
535
+ # Dependency ids per active step (mirrors _build_scheduler_state), used to
536
+ # re-arm after a rollback.
537
+ active_ids = set(state.step_by_id)
538
+ dep_ids: dict[str, set[str]] = {}
539
+ for i, s in enumerate(steps):
540
+ if s.id not in active_ids:
541
+ continue
542
+ if s.depends_on is None:
543
+ dep_ids[s.id] = {p.id for p in steps[:i] if p.id in active_ids}
544
+ else:
545
+ dep_ids[s.id] = {d.task_step_id for d in s.depends_on}
546
+
547
+ in_flight: dict[str, asyncio.Task] = {}
548
+ task_to_step_id: dict[asyncio.Task, str] = {}
549
+ first_error: BaseException | None = None
550
+ first_error_step_id: str | None = None
551
+ retry_counts: dict[str, int] = {}
552
+ epoch = 0
553
+ # Execution windows (dispatch tick, end tick) per step, for the retry's concurrency
554
+ # gate: a step that ran while a span member ran may have journaled its writes.
555
+ clock = itertools.count()
556
+ started: dict[str, int] = {}
557
+ ended: dict[str, int] = {}
558
+
559
+ async def _heartbeat_step(
560
+ step: TaskStep, pre_context: TaskStepContext, status, dispatch_epoch: int,
561
+ ) -> None:
562
+ if not instance_id:
563
+ return
564
+ ops = build_context_update_ops(pre_context, context)
565
+ # Module attribute (not a bound import) so record_step_complete stays
566
+ # patchable; the upsert-by-step_id keeps a re-dispatched step
567
+ # idempotent, and dispatch_epoch fences a rolled-back straggler.
568
+ await _store.record_step_complete(
569
+ instance_id, step.id, ops, total, Task._utc_now_str(),
570
+ status=status, attempt_epoch=dispatch_epoch,
571
+ )
572
+
573
+ def _unblock_dependents(step_id: str) -> None:
574
+ state.completed.add(step_id)
575
+ for dep_id in state.dependents[step_id]:
576
+ state.pending[dep_id] -= 1
577
+ if state.pending[dep_id] == 0 and dep_id not in state.completed:
578
+ state.ready.append(state.step_by_id[dep_id])
579
+
580
+ def _rearm() -> None:
581
+ """Recompute readiness from ``state.completed`` after a rollback so the
582
+ re-dispatched span (and any other uncompleted, dep-satisfied step) runs.
583
+ Nothing is in flight here: the drain preceding a rollback emptied it."""
584
+ for sid in state.step_by_id:
585
+ state.pending[sid] = sum(1 for d in dep_ids[sid] if d not in state.completed)
586
+ state.ready = [
587
+ state.step_by_id[s.id] for s in steps
588
+ if s.id in active_ids and s.id not in state.completed and state.pending[s.id] == 0
589
+ ]
590
+
591
+ async def _run_one(step: TaskStep, idx: int, dispatch_epoch: int) -> None:
592
+ if on_step_start:
593
+ result = on_step_start(idx, total, step, context)
594
+ if inspect.isawaitable(result):
595
+ await result
596
+ # Snapshot context before dispatch so we can emit only the fields
597
+ # this step actually changed — concurrent sibling writes to other
598
+ # fields are preserved by Mongo's single-document atomicity.
599
+ pre_context = copy.deepcopy(context)
600
+ started_mono = time.monotonic()
601
+ started_utc = datetime.now(timezone.utc).isoformat()
602
+ try:
603
+ await step.execute(context)
604
+ except asyncio.CancelledError:
605
+ raise
606
+ except BaseException as exc:
607
+ duration = time.monotonic() - started_mono
608
+ fatal = getattr(step, "fail_task_on_error", True)
609
+ context.metadata.setdefault("failed_steps", []).append({
610
+ "step_id": step.id, "step_type": step.type,
611
+ "error": str(exc), "error_type": type(exc).__name__,
612
+ "started_at_utc": started_utc, "duration_seconds": duration,
613
+ "is_fatal": fatal,
614
+ })
615
+ if not fatal:
616
+ await _heartbeat_step(step, pre_context, _store.TaskStepStatus.FAILURE, dispatch_epoch)
617
+ raise
618
+ duration = time.monotonic() - started_mono
619
+ await _heartbeat_step(step, pre_context, _store.TaskStepStatus.SUCCESS, dispatch_epoch)
620
+ logger.info(
621
+ "%sStep %s (%s) completed in %.1fs",
622
+ log_prefix, step.id, step.type, duration,
623
+ )
624
+ if on_step_complete:
625
+ result = on_step_complete(idx, total, step, context, duration)
626
+ if inspect.isawaitable(result):
627
+ await result
628
+
629
+ async def _drain_in_flight() -> set[str]:
630
+ """Cancel and await every in-flight step; return the ids cancelled.
631
+
632
+ A sibling cancelled mid-execute may hold partial context writes, so the
633
+ caller rolls those ids back too. Cancellation cannot interrupt an
634
+ in-flight persist; the epoch fence, not the cancel, stops a drained
635
+ step's late write."""
636
+ cancelled = set(in_flight)
637
+ tick = next(clock)
638
+ for sid in cancelled:
639
+ ended[sid] = tick
640
+ for t in list(in_flight.values()):
641
+ t.cancel()
642
+ await asyncio.gather(*in_flight.values(), return_exceptions=True)
643
+ in_flight.clear()
644
+ task_to_step_id.clear()
645
+ return cancelled
646
+
647
+ try:
648
+ while state.ready or in_flight:
649
+ while state.ready and first_error is None:
650
+ step = state.ready.pop(0)
651
+ started[step.id] = next(clock)
652
+ t = asyncio.create_task(_run_one(step, index_of[step.id], epoch))
653
+ in_flight[step.id] = t
654
+ task_to_step_id[t] = step.id
655
+ if not in_flight:
656
+ break
657
+ done, _ = await asyncio.wait(
658
+ list(in_flight.values()), return_when=asyncio.FIRST_COMPLETED,
659
+ )
660
+ # Classify the batch: a failed step with retry budget is a retry
661
+ # candidate (tolerance applies only once the budget is spent); a
662
+ # tolerant failure continues; any other failure is terminal.
663
+ retryable: list[tuple[TaskStep, RetryConfig, BaseException]] = []
664
+ for t in done:
665
+ step_id = task_to_step_id.pop(t)
666
+ in_flight.pop(step_id)
667
+ ended[step_id] = next(clock)
668
+ exc = t.exception()
669
+ if exc is None:
670
+ _unblock_dependents(step_id)
671
+ continue
672
+ step_obj = state.step_by_id[step_id]
673
+ retry_cfg = getattr(step_obj, "retry_config", None)
674
+ if retry_cfg is not None and retry_counts.get(step_id, 0) < retry_cfg.max_retries:
675
+ retryable.append((step_obj, retry_cfg, exc))
676
+ continue
677
+ if not getattr(step_obj, "fail_task_on_error", True):
678
+ logger.warning(
679
+ "%sStep %s failed (tolerant, continuing): %s", log_prefix, step_id, exc,
680
+ )
681
+ _unblock_dependents(step_id)
682
+ continue
683
+ # Every fatal failure is logged; only the first is the task's error.
684
+ logger.error("%sStep %s failed (fatal): %s", log_prefix, step_id, exc)
685
+ if first_error is None:
686
+ first_error, first_error_step_id = exc, step_id
687
+
688
+ if first_error is not None:
689
+ continue # a terminal fatal wins; the loop drains what's left
690
+ if len(retryable) > 1:
691
+ # Two overlapping or independent spans can't be rebuilt in one
692
+ # pass (a non-rolled-back loser would silently re-run on a dirty
693
+ # world), so a multi-retryable batch is terminal.
694
+ step_obj, _, exc = retryable[0]
695
+ first_error, first_error_step_id = exc, step_obj.id
696
+ logger.error(
697
+ "%s%d retryable steps failed in one batch (%s); failing the task "
698
+ "rather than retrying overlapping spans.",
699
+ log_prefix, len(retryable), ", ".join(s.id for s, _, _ in retryable),
700
+ )
701
+ elif len(retryable) == 1:
702
+ step_obj, retry_cfg, exc = retryable[0]
703
+ retry_counts[step_obj.id] = retry_counts.get(step_obj.id, 0) + 1
704
+ cancelled = await _drain_in_flight()
705
+ now_tick = next(clock)
706
+ outcome = await _roll_back_span(
707
+ context, instance_id, steps, state.dependents,
708
+ failed_id=step_obj.id, resume_from=retry_cfg.retry_from_step_id,
709
+ cancelled_ids=cancelled, attempt=retry_counts[step_obj.id] + 1,
710
+ windows={sid: (t0, ended.get(sid, now_tick)) for sid, t0 in started.items()},
711
+ completed_ids=set(state.completed), log_prefix=log_prefix,
712
+ )
713
+ if outcome is None:
714
+ # Could not roll back (refused, unpersisted, or a concurrent survivor):
715
+ # fail with the original error, live context untouched.
716
+ first_error, first_error_step_id = exc, step_obj.id
717
+ else:
718
+ rollback_ids, epoch = outcome
719
+ for sid in rollback_ids:
720
+ state.completed.discard(sid)
721
+ _rearm()
722
+ except (asyncio.CancelledError, Exception) as exc:
723
+ await _drain_in_flight()
724
+ if instance_id:
725
+ # Record with the fatal step's attribution: a cancellation landing
726
+ # during the drain would otherwise overwrite the instance record
727
+ # with the CancelledError and a null failing-step id.
728
+ await _store.record_task_failure(
729
+ instance_id, first_error_step_id, context,
730
+ first_error if first_error is not None else exc, Task._utc_now_str(),
731
+ )
732
+ raise
733
+
734
+ return first_error, first_error_step_id
735
+
736
+ def _build_scheduler_state(self, steps: list, start_step: int, end_step: int | None = None) -> _SchedulerState:
737
+ # Truncate steps at end_step (exclusive); depends_on only ever points at
738
+ # earlier steps, so active steps never reference truncated ones.
739
+ active_steps = steps[:end_step] if end_step is not None else steps
740
+ step_by_id = {s.id: s for s in active_steps}
741
+ dependents: dict[str, list[str]] = {step_id: [] for step_id in step_by_id}
742
+ pending: dict[str, int] = {}
743
+ for i, step in enumerate(active_steps):
744
+ # When depends_on is None, we assume all prior steps are dependencies.
745
+ if step.depends_on is None:
746
+ dependency_step_ids = {s.id for s in active_steps[:i]}
747
+ else:
748
+ dependency_step_ids = {d.task_step_id for d in step.depends_on}
749
+ for dependency_step_id in dependency_step_ids:
750
+ dependents[dependency_step_id].append(step.id)
751
+ pending[step.id] = len(dependency_step_ids)
752
+
753
+ completed: set[str] = set()
754
+ for s in active_steps[:start_step]:
755
+ completed.add(s.id)
756
+ for dependent_step_id in dependents[s.id]:
757
+ pending[dependent_step_id] = max(0, pending[dependent_step_id] - 1)
758
+
759
+ ready: list[TaskStep] = [
760
+ s for i, s in enumerate(active_steps)
761
+ if i >= start_step and pending[s.id] == 0
762
+ ]
763
+ return _SchedulerState(
764
+ step_by_id=step_by_id,
765
+ dependents=dependents,
766
+ pending=pending,
767
+ completed=completed,
768
+ ready=ready,
769
+ )
770
+
771
+ @staticmethod
772
+ async def _handle_failure(
773
+ failing_step_id: str | None,
774
+ error: BaseException,
775
+ context: TaskStepContext,
776
+ instance_id: str | None,
777
+ ) -> None:
778
+ """Record the terminal failure on the instance and re-raise it."""
779
+ from .store import record_task_failure
780
+
781
+ if instance_id:
782
+ await record_task_failure(
783
+ instance_id, failing_step_id, context, error, Task._utc_now_str(),
784
+ )
785
+ raise error
786
+
787
+ @staticmethod
788
+ def _utc_now_str() -> str:
789
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
790
+
791
+ @classmethod
792
+ def get(cls, id: str, version: Optional[int] = None) -> "Task":
793
+ from .store import get_task_store
794
+
795
+ return get_task_store().get(id, version)
796
+
797
+ @classmethod
798
+ def put(cls, **kwargs) -> "Task":
799
+ from .store import get_task_store
800
+
801
+ kwargs.setdefault("version", None)
802
+ instance = cls(**kwargs)
803
+ return get_task_store().put_document(instance)
804
+
805
+ @classmethod
806
+ def query(cls) -> "TaskQuery":
807
+ from .store import TaskQuery, get_task_store
808
+
809
+ return TaskQuery(get_task_store())
810
+
811
+ @staticmethod
812
+ def get_instance(instance_id: str) -> "TaskInstance":
813
+ from .store import get_task_instance_store
814
+
815
+ return get_task_instance_store().get(instance_id)