secondshift 0.1.0b12__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 (371) hide show
  1. secondshift/__init__.py +1 -0
  2. secondshift/__main__.py +3 -0
  3. secondshift/cli.py +17 -0
  4. secondshift/config/__init__.py +0 -0
  5. secondshift/config/api.py +23 -0
  6. secondshift/config/app.py +62 -0
  7. secondshift/config/constants.py +279 -0
  8. secondshift/config/container_env.py +118 -0
  9. secondshift/config/first_run.py +40 -0
  10. secondshift/config/formatting.py +5 -0
  11. secondshift/config/gunicorn.py +31 -0
  12. secondshift/config/logging.py +40 -0
  13. secondshift/config/settings/__init__.py +0 -0
  14. secondshift/config/settings/base.py +524 -0
  15. secondshift/config/settings/dev.py +14 -0
  16. secondshift/config/settings/local.py +23 -0
  17. secondshift/config/settings/production.py +113 -0
  18. secondshift/config/settings/runner.py +140 -0
  19. secondshift/config/settings/test.py +47 -0
  20. secondshift/config/static_storage.py +45 -0
  21. secondshift/config/urls.py +42 -0
  22. secondshift/connections/__init__.py +0 -0
  23. secondshift/connections/access.py +27 -0
  24. secondshift/connections/admin.py +25 -0
  25. secondshift/connections/agents.py +224 -0
  26. secondshift/connections/api.py +747 -0
  27. secondshift/connections/apps.py +6 -0
  28. secondshift/connections/catalog.py +96 -0
  29. secondshift/connections/checks.py +22 -0
  30. secondshift/connections/dao.py +305 -0
  31. secondshift/connections/errors.py +13 -0
  32. secondshift/connections/execution.py +262 -0
  33. secondshift/connections/external.py +258 -0
  34. secondshift/connections/github.py +123 -0
  35. secondshift/connections/http_api/__init__.py +1 -0
  36. secondshift/connections/http_api/client.py +161 -0
  37. secondshift/connections/http_api/connections.py +187 -0
  38. secondshift/connections/http_api/spec.py +382 -0
  39. secondshift/connections/mcp/__init__.py +1 -0
  40. secondshift/connections/mcp/base.py +139 -0
  41. secondshift/connections/mcp/client.py +353 -0
  42. secondshift/connections/mcp/custom.py +86 -0
  43. secondshift/connections/mcp/github.py +256 -0
  44. secondshift/connections/mcp/oauth.py +352 -0
  45. secondshift/connections/mcp/providers.py +15 -0
  46. secondshift/connections/mcp/schemas.py +77 -0
  47. secondshift/connections/mcp/sentry.py +135 -0
  48. secondshift/connections/mcp/signin.py +206 -0
  49. secondshift/connections/migration_helpers.py +28 -0
  50. secondshift/connections/migrations/0001_initial.py +101 -0
  51. secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
  52. secondshift/connections/migrations/0003_require_approval.py +23 -0
  53. secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
  54. secondshift/connections/migrations/0005_agentconnection.py +36 -0
  55. secondshift/connections/migrations/0006_remove_is_default.py +23 -0
  56. secondshift/connections/migrations/0007_agent_last_check.py +18 -0
  57. secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
  58. secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
  59. secondshift/connections/migrations/0010_connection_attribution.py +18 -0
  60. secondshift/connections/migrations/__init__.py +0 -0
  61. secondshift/connections/model_providers.py +441 -0
  62. secondshift/connections/models.py +246 -0
  63. secondshift/connections/outbound.py +317 -0
  64. secondshift/connections/repository_ref.py +19 -0
  65. secondshift/connections/schemas.py +313 -0
  66. secondshift/connections/serialization.py +100 -0
  67. secondshift/connections/services.py +94 -0
  68. secondshift/connections/tools.py +38 -0
  69. secondshift/connections/webhooks.py +107 -0
  70. secondshift/core/__init__.py +0 -0
  71. secondshift/core/adapters.py +38 -0
  72. secondshift/core/admin.py +5 -0
  73. secondshift/core/api.py +104 -0
  74. secondshift/core/apps.py +20 -0
  75. secondshift/core/launcher.py +311 -0
  76. secondshift/core/local_agents.py +90 -0
  77. secondshift/core/local_output.py +120 -0
  78. secondshift/core/management/__init__.py +0 -0
  79. secondshift/core/management/commands/__init__.py +0 -0
  80. secondshift/core/management/commands/bootstrap_dev.py +33 -0
  81. secondshift/core/management/commands/dev_reset_db.py +204 -0
  82. secondshift/core/management/commands/login.py +23 -0
  83. secondshift/core/management/commands/logs.py +58 -0
  84. secondshift/core/management/commands/status.py +46 -0
  85. secondshift/core/management/commands/up.py +244 -0
  86. secondshift/core/middleware.py +21 -0
  87. secondshift/core/migrations/__init__.py +0 -0
  88. secondshift/core/schemas.py +71 -0
  89. secondshift/core/signin.py +58 -0
  90. secondshift/core/static/core/account.css +149 -0
  91. secondshift/core/static/core/admin.css +135 -0
  92. secondshift/core/templatetags/__init__.py +0 -0
  93. secondshift/core/templatetags/accounts.py +20 -0
  94. secondshift/core/terminal.py +27 -0
  95. secondshift/core/views.py +10 -0
  96. secondshift/gateway/__init__.py +0 -0
  97. secondshift/gateway/apps.py +6 -0
  98. secondshift/gateway/dao.py +137 -0
  99. secondshift/gateway/metering.py +171 -0
  100. secondshift/gateway/migrations/0001_initial.py +54 -0
  101. secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
  102. secondshift/gateway/migrations/__init__.py +0 -0
  103. secondshift/gateway/models.py +61 -0
  104. secondshift/gateway/pricing.py +39 -0
  105. secondshift/gateway/proxy.py +369 -0
  106. secondshift/gateway/tokens.py +68 -0
  107. secondshift/gateway/urls.py +17 -0
  108. secondshift/gateway/views.py +11 -0
  109. secondshift/github/__init__.py +6 -0
  110. secondshift/github/app.py +109 -0
  111. secondshift/github/authors.py +56 -0
  112. secondshift/github/credentials.py +59 -0
  113. secondshift/github/errors.py +2 -0
  114. secondshift/github/git.py +56 -0
  115. secondshift/github/identity.py +35 -0
  116. secondshift/github/installations.py +74 -0
  117. secondshift/github/issues.py +75 -0
  118. secondshift/github/permissions.py +49 -0
  119. secondshift/github/pulls.py +49 -0
  120. secondshift/github/repos.py +52 -0
  121. secondshift/github/rest.py +93 -0
  122. secondshift/github/ruff.toml +16 -0
  123. secondshift/github/tokens.py +52 -0
  124. secondshift/jobs/__init__.py +0 -0
  125. secondshift/jobs/access.py +12 -0
  126. secondshift/jobs/admin.py +83 -0
  127. secondshift/jobs/api.py +184 -0
  128. secondshift/jobs/apps.py +6 -0
  129. secondshift/jobs/automatic_grants.py +115 -0
  130. secondshift/jobs/autosync.py +78 -0
  131. secondshift/jobs/capabilities.py +43 -0
  132. secondshift/jobs/dao.py +258 -0
  133. secondshift/jobs/drafts.py +112 -0
  134. secondshift/jobs/grants.py +116 -0
  135. secondshift/jobs/limits.py +26 -0
  136. secondshift/jobs/management/__init__.py +0 -0
  137. secondshift/jobs/management/commands/__init__.py +0 -0
  138. secondshift/jobs/manifest.py +364 -0
  139. secondshift/jobs/migrations/0001_initial.py +87 -0
  140. secondshift/jobs/migrations/0002_job_history.py +18 -0
  141. secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
  142. secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
  143. secondshift/jobs/migrations/0005_job_triggers.py +18 -0
  144. secondshift/jobs/migrations/0006_job_webhook.py +18 -0
  145. secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
  146. secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
  147. secondshift/jobs/migrations/0009_job_subagents.py +18 -0
  148. secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
  149. secondshift/jobs/migrations/__init__.py +0 -0
  150. secondshift/jobs/models.py +199 -0
  151. secondshift/jobs/needs.py +154 -0
  152. secondshift/jobs/plan.py +176 -0
  153. secondshift/jobs/planning.py +386 -0
  154. secondshift/jobs/readiness.py +46 -0
  155. secondshift/jobs/repository_sync.py +199 -0
  156. secondshift/jobs/scheduling.py +26 -0
  157. secondshift/jobs/schemas.py +207 -0
  158. secondshift/jobs/serialization.py +218 -0
  159. secondshift/jobs/skill_needs.py +189 -0
  160. secondshift/jobs/sync.py +252 -0
  161. secondshift/jobs/tool_policy.py +82 -0
  162. secondshift/jobs/tool_rules.py +200 -0
  163. secondshift/mcp_server/__init__.py +6 -0
  164. secondshift/mcp_server/api.py +79 -0
  165. secondshift/mcp_server/apps.py +7 -0
  166. secondshift/mcp_server/audit.py +22 -0
  167. secondshift/mcp_server/core.py +12 -0
  168. secondshift/mcp_server/dao.py +23 -0
  169. secondshift/mcp_server/migrations/0001_initial.py +33 -0
  170. secondshift/mcp_server/migrations/__init__.py +0 -0
  171. secondshift/mcp_server/models.py +40 -0
  172. secondshift/mcp_server/schemas.py +204 -0
  173. secondshift/mcp_server/tools/__init__.py +9 -0
  174. secondshift/mcp_server/tools/jobs.py +106 -0
  175. secondshift/mcp_server/tools/manifests.py +127 -0
  176. secondshift/mcp_server/tools/repositories.py +42 -0
  177. secondshift/mcp_server/tools/runs.py +86 -0
  178. secondshift/mcp_server/tools/tasks.py +103 -0
  179. secondshift/mcp_server/tools_registry.py +81 -0
  180. secondshift/repositories/__init__.py +0 -0
  181. secondshift/repositories/access.py +13 -0
  182. secondshift/repositories/admin.py +19 -0
  183. secondshift/repositories/api.py +296 -0
  184. secondshift/repositories/apps.py +6 -0
  185. secondshift/repositories/dao.py +114 -0
  186. secondshift/repositories/external_skills.py +224 -0
  187. secondshift/repositories/github.py +27 -0
  188. secondshift/repositories/github_access.py +57 -0
  189. secondshift/repositories/github_check.py +279 -0
  190. secondshift/repositories/github_sync.py +181 -0
  191. secondshift/repositories/github_views.py +90 -0
  192. secondshift/repositories/local.py +198 -0
  193. secondshift/repositories/management/__init__.py +0 -0
  194. secondshift/repositories/management/commands/__init__.py +0 -0
  195. secondshift/repositories/management/commands/prune_source_cache.py +15 -0
  196. secondshift/repositories/migrations/0001_initial.py +45 -0
  197. secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
  198. secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
  199. secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
  200. secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
  201. secondshift/repositories/migrations/0006_allow_issues.py +18 -0
  202. secondshift/repositories/migrations/__init__.py +0 -0
  203. secondshift/repositories/mirrors.py +50 -0
  204. secondshift/repositories/models.py +104 -0
  205. secondshift/repositories/pull_requests.py +199 -0
  206. secondshift/repositories/schemas.py +102 -0
  207. secondshift/repositories/serialization.py +133 -0
  208. secondshift/repositories/skill_source.py +64 -0
  209. secondshift/repositories/snapshots.py +238 -0
  210. secondshift/repositories/sources.py +256 -0
  211. secondshift/repositories/status.py +67 -0
  212. secondshift/runner/README.md +65 -0
  213. secondshift/runner/__init__.py +1 -0
  214. secondshift/runner/agent_credentials.py +89 -0
  215. secondshift/runner/agent_image/Dockerfile +16 -0
  216. secondshift/runner/agent_image/agent.py +132 -0
  217. secondshift/runner/agent_image/claude.py +254 -0
  218. secondshift/runner/agent_image/codex.py +84 -0
  219. secondshift/runner/agent_image/outcomes.py +120 -0
  220. secondshift/runner/agent_image/subagent_hook.py +71 -0
  221. secondshift/runner/agent_protocol.py +41 -0
  222. secondshift/runner/agents.py +254 -0
  223. secondshift/runner/api.py +123 -0
  224. secondshift/runner/apps.py +6 -0
  225. secondshift/runner/client.py +168 -0
  226. secondshift/runner/collection.py +276 -0
  227. secondshift/runner/dao.py +64 -0
  228. secondshift/runner/dev.py +19 -0
  229. secondshift/runner/images.py +57 -0
  230. secondshift/runner/migrations/0001_initial.py +48 -0
  231. secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
  232. secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
  233. secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
  234. secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
  235. secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
  236. secondshift/runner/migrations/__init__.py +0 -0
  237. secondshift/runner/models.py +53 -0
  238. secondshift/runner/protocol.py +97 -0
  239. secondshift/runner/schemas.py +36 -0
  240. secondshift/runner/services.py +465 -0
  241. secondshift/runner/sources.py +130 -0
  242. secondshift/runner/urls.py +7 -0
  243. secondshift/runs/__init__.py +0 -0
  244. secondshift/runs/access.py +13 -0
  245. secondshift/runs/admin.py +89 -0
  246. secondshift/runs/api.py +211 -0
  247. secondshift/runs/apps.py +11 -0
  248. secondshift/runs/artifacts.py +261 -0
  249. secondshift/runs/dao.py +640 -0
  250. secondshift/runs/deliveries.py +80 -0
  251. secondshift/runs/execution/__init__.py +0 -0
  252. secondshift/runs/execution/access_needs.py +89 -0
  253. secondshift/runs/execution/agent.py +91 -0
  254. secondshift/runs/execution/agent_broker.py +198 -0
  255. secondshift/runs/execution/budgets.py +177 -0
  256. secondshift/runs/execution/cli_agent.py +167 -0
  257. secondshift/runs/execution/consent.py +58 -0
  258. secondshift/runs/execution/deadlines.py +47 -0
  259. secondshift/runs/execution/external_tools.py +84 -0
  260. secondshift/runs/execution/issues.py +99 -0
  261. secondshift/runs/execution/model_gateway.py +90 -0
  262. secondshift/runs/execution/payload.py +63 -0
  263. secondshift/runs/execution/result.py +59 -0
  264. secondshift/runs/execution/run.py +345 -0
  265. secondshift/runs/execution/subagents.py +246 -0
  266. secondshift/runs/execution/tool_broker.py +123 -0
  267. secondshift/runs/execution/workspace_tools.py +248 -0
  268. secondshift/runs/history.py +182 -0
  269. secondshift/runs/inputs.py +49 -0
  270. secondshift/runs/issue_delivery.py +144 -0
  271. secondshift/runs/management/__init__.py +0 -0
  272. secondshift/runs/management/commands/__init__.py +0 -0
  273. secondshift/runs/management/commands/prune_runs.py +12 -0
  274. secondshift/runs/management/commands/run_dbos_worker.py +129 -0
  275. secondshift/runs/migration_helpers.py +313 -0
  276. secondshift/runs/migrations/0001_initial.py +69 -0
  277. secondshift/runs/migrations/0002_run_started_by.py +18 -0
  278. secondshift/runs/migrations/0003_agentlease.py +25 -0
  279. secondshift/runs/migrations/0004_task_assignment.py +39 -0
  280. secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
  281. secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
  282. secondshift/runs/migrations/0007_run_delivery.py +35 -0
  283. secondshift/runs/migrations/0008_run_outputs.py +18 -0
  284. secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
  285. secondshift/runs/migrations/0010_run_issue.py +32 -0
  286. secondshift/runs/migrations/0011_issues_delivery.py +23 -0
  287. secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
  288. secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
  289. secondshift/runs/migrations/0014_run_access_need.py +30 -0
  290. secondshift/runs/migrations/__init__.py +0 -0
  291. secondshift/runs/models.py +340 -0
  292. secondshift/runs/orchestration/__init__.py +0 -0
  293. secondshift/runs/orchestration/admission.py +15 -0
  294. secondshift/runs/orchestration/dbos_runtime.py +43 -0
  295. secondshift/runs/orchestration/dispatch.py +130 -0
  296. secondshift/runs/orchestration/periodic.py +39 -0
  297. secondshift/runs/orchestration/reconciliation.py +98 -0
  298. secondshift/runs/orchestration/schedules.py +51 -0
  299. secondshift/runs/orchestration/workflows.py +480 -0
  300. secondshift/runs/outputs.py +52 -0
  301. secondshift/runs/publishing.py +162 -0
  302. secondshift/runs/queue.py +26 -0
  303. secondshift/runs/reporting/__init__.py +0 -0
  304. secondshift/runs/reporting/collection.py +26 -0
  305. secondshift/runs/reporting/connection_usage.py +45 -0
  306. secondshift/runs/reporting/failures.py +150 -0
  307. secondshift/runs/reporting/health.py +158 -0
  308. secondshift/runs/reporting/notifications.py +258 -0
  309. secondshift/runs/reporting/queries.py +60 -0
  310. secondshift/runs/retention.py +35 -0
  311. secondshift/runs/schemas.py +192 -0
  312. secondshift/runs/serialization.py +245 -0
  313. secondshift/runs/services.py +176 -0
  314. secondshift/runs/signals.py +25 -0
  315. secondshift/runs/submissions.py +236 -0
  316. secondshift/runs/transitions.py +84 -0
  317. secondshift/templates/account/password_reset_help_text.html +1 -0
  318. secondshift/templates/admin/base_site.html +24 -0
  319. secondshift/templates/allauth/elements/provider.html +9 -0
  320. secondshift/templates/allauth/layouts/base.html +67 -0
  321. secondshift/templates/socialaccount/snippets/login.html +9 -0
  322. secondshift/templates/workspaces/invite_unusable.html +10 -0
  323. secondshift/triggers/__init__.py +0 -0
  324. secondshift/triggers/admin.py +19 -0
  325. secondshift/triggers/api.py +116 -0
  326. secondshift/triggers/apps.py +6 -0
  327. secondshift/triggers/dao.py +66 -0
  328. secondshift/triggers/filters.py +48 -0
  329. secondshift/triggers/migrations/0001_initial.py +55 -0
  330. secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
  331. secondshift/triggers/migrations/__init__.py +0 -0
  332. secondshift/triggers/models.py +83 -0
  333. secondshift/triggers/schemas.py +58 -0
  334. secondshift/triggers/serialization.py +91 -0
  335. secondshift/triggers/services.py +246 -0
  336. secondshift/triggers/verification.py +84 -0
  337. secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
  338. secondshift/web/assets/index-D27gB9_L.css +1 -0
  339. secondshift/web/favicon.svg +19 -0
  340. secondshift/web/index.html +16 -0
  341. secondshift/web/secondshift-mark.svg +16 -0
  342. secondshift/workspaces/__init__.py +0 -0
  343. secondshift/workspaces/access.py +181 -0
  344. secondshift/workspaces/admin.py +37 -0
  345. secondshift/workspaces/api.py +264 -0
  346. secondshift/workspaces/apps.py +6 -0
  347. secondshift/workspaces/context_processors.py +11 -0
  348. secondshift/workspaces/dao.py +281 -0
  349. secondshift/workspaces/invites.py +150 -0
  350. secondshift/workspaces/limits.py +197 -0
  351. secondshift/workspaces/management/__init__.py +0 -0
  352. secondshift/workspaces/management/commands/__init__.py +0 -0
  353. secondshift/workspaces/management/commands/create_workspace.py +39 -0
  354. secondshift/workspaces/migrations/0001_initial.py +67 -0
  355. secondshift/workspaces/migrations/0002_api_token.py +34 -0
  356. secondshift/workspaces/migrations/0003_execution_default.py +29 -0
  357. secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
  358. secondshift/workspaces/migrations/__init__.py +0 -0
  359. secondshift/workspaces/models.py +169 -0
  360. secondshift/workspaces/owners.py +58 -0
  361. secondshift/workspaces/schemas.py +149 -0
  362. secondshift/workspaces/serialization.py +138 -0
  363. secondshift/workspaces/server_settings.py +140 -0
  364. secondshift/workspaces/services.py +94 -0
  365. secondshift/workspaces/tokens.py +108 -0
  366. secondshift/workspaces/views.py +32 -0
  367. secondshift-0.1.0b12.dist-info/METADATA +270 -0
  368. secondshift-0.1.0b12.dist-info/RECORD +371 -0
  369. secondshift-0.1.0b12.dist-info/WHEEL +4 -0
  370. secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
  371. secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,340 @@
1
+ import uuid
2
+ from functools import cached_property
3
+
4
+ from django.conf import settings
5
+ from django.db import models
6
+ from django.utils import timezone
7
+
8
+ from secondshift.config.constants import RunStart
9
+ from secondshift.connections.models import Connection
10
+ from secondshift.jobs.models import Job
11
+ from secondshift.jobs.plan import ExecutionPlan
12
+ from secondshift.repositories.models import Repository
13
+ from secondshift.runs.inputs import RunInput
14
+ from secondshift.runs.outputs import RunOutputs
15
+
16
+
17
+ class RunFailure(models.TextChoices):
18
+ """Why a run failed. The label is what pages show beside the reason."""
19
+
20
+ # The stage a workflow error stopped the run in.
21
+ STARTING = "starting", "Start"
22
+ CHECKOUT = "checkout", "Checkout"
23
+ SANDBOX = "sandbox", "Sandbox"
24
+ SKILL = "skill", "Skill"
25
+ FINISHING = "finishing", "Recording"
26
+ # A run limit stopped the agent.
27
+ TIMEOUT = "timeout", "Timed out"
28
+ BUDGET = "budget", "Budget used up"
29
+ MODEL_PROVIDER = "model_provider", "Model provider"
30
+ TOOL_RULE = "tool_rule", "Tool restriction"
31
+ # The queue never took the run, so it does not count as started.
32
+ NOT_QUEUED = "not_queued", "Run"
33
+ OTHER = "other", "Run"
34
+
35
+
36
+ class Task(models.Model):
37
+ """One assignment in a repository. Each run of it is an attempt.
38
+
39
+ A job run and an ad hoc task each get one. Ad hoc tasks also keep how
40
+ they were submitted, so a repeated submission finds its first run.
41
+ """
42
+
43
+ # Runs protect their task, so only a task without attempts goes with it.
44
+ repository = models.ForeignKey(
45
+ Repository, on_delete=models.CASCADE, related_name="tasks"
46
+ )
47
+ # The client's idempotency key for an ad hoc task. Empty for job runs.
48
+ submission_key = models.CharField(max_length=120, blank=True, default="")
49
+ # What was submitted with the key. Another submission may not reuse it.
50
+ fingerprint = models.CharField(max_length=64, blank=True, default="")
51
+ submitted_by = models.ForeignKey(
52
+ settings.AUTH_USER_MODEL,
53
+ on_delete=models.SET_NULL,
54
+ related_name="secondshift_tasks",
55
+ null=True,
56
+ blank=True,
57
+ )
58
+ created_at = models.DateTimeField(default=timezone.now, editable=False)
59
+
60
+ class Meta:
61
+ ordering = ["-created_at"]
62
+ constraints = [
63
+ models.UniqueConstraint(
64
+ fields=["repository", "submission_key"],
65
+ condition=~models.Q(submission_key=""),
66
+ name="unique_repository_submission",
67
+ ),
68
+ ]
69
+
70
+
71
+ class Run(models.Model):
72
+ class Status(models.TextChoices):
73
+ QUEUED = "queued", "Queued"
74
+ RUNNING = "running", "Running"
75
+ SUCCEEDED = "succeeded", "Succeeded"
76
+ FAILED = "failed", "Failed"
77
+ CANCELED = "canceled", "Canceled"
78
+
79
+ # A run in one of these has not finished yet.
80
+ ACTIVE_STATUSES = (Status.QUEUED, Status.RUNNING)
81
+ # A run in one of these is done and will not change status again.
82
+ FINISHED_STATUSES = (Status.SUCCEEDED, Status.FAILED, Status.CANCELED)
83
+
84
+ job = models.ForeignKey(
85
+ Job, on_delete=models.PROTECT, related_name="runs", null=True, blank=True
86
+ )
87
+ task = models.ForeignKey(Task, on_delete=models.PROTECT, related_name="runs")
88
+ status = models.CharField(
89
+ max_length=16,
90
+ choices=Status.choices,
91
+ default=Status.QUEUED,
92
+ )
93
+ commit_sha = models.CharField(max_length=64)
94
+ scheduled_for = models.DateTimeField()
95
+ started_by = models.CharField(
96
+ max_length=16, choices=[(item.value, item.value.title()) for item in RunStart]
97
+ )
98
+ started_at = models.DateTimeField(null=True, blank=True)
99
+ finished_at = models.DateTimeField(null=True, blank=True)
100
+ sandbox_runtime = models.CharField(max_length=32, blank=True)
101
+ exit_code = models.IntegerField(null=True, blank=True)
102
+ summary = models.TextField(blank=True)
103
+ failure = models.CharField(max_length=16, choices=RunFailure.choices, blank=True)
104
+ failure_reason = models.TextField(blank=True)
105
+ # Inputs beyond the plan: an ad hoc task's definition and input, a
106
+ # trigger's payload and repeat key, and the earlier runs it reads.
107
+ input_snapshot = models.JSONField(default=dict, blank=True)
108
+ # The frozen jobs.plan.ExecutionPlan. Read it through `plan`.
109
+ execution_plan = models.JSONField()
110
+ # The agent login the plan chose. Its runs per day count against its cap.
111
+ agent_connection = models.ForeignKey(
112
+ "connections.AgentConnection",
113
+ on_delete=models.SET_NULL,
114
+ null=True,
115
+ blank=True,
116
+ related_name="runs",
117
+ )
118
+ # The full report is in run_artifacts storage at output_path. Only its
119
+ # metadata and the tail are kept here.
120
+ output_excerpt = models.TextField(blank=True)
121
+ # The rendered input (instructions, prompt and the skill at its commit),
122
+ # stored next to the output in run_artifacts.
123
+ input_path = models.CharField(max_length=500, blank=True)
124
+ input_size_bytes = models.BigIntegerField(null=True, blank=True)
125
+ output_path = models.CharField(max_length=500, blank=True)
126
+ output_size_bytes = models.BigIntegerField(null=True, blank=True)
127
+ output_sha256 = models.CharField(max_length=64, blank=True)
128
+ output_truncated = models.BooleanField(default=False)
129
+ # What the sandbox left behind, stored in run_artifacts: the agent's file
130
+ # changes and the files it saved. See artifacts.store_collection.
131
+ outputs = models.JSONField(default=dict, blank=True, db_default={})
132
+ dbos_workflow_id = models.CharField(max_length=200, blank=True, db_index=True)
133
+ # Sent to the runner so its session can never match one from an older run
134
+ # that had the same ID, for example after a database reset or restore.
135
+ runner_session_key = models.UUIDField(default=uuid.uuid4, editable=False)
136
+ created_at = models.DateTimeField(auto_now_add=True)
137
+
138
+ class Meta:
139
+ ordering = ["-created_at"]
140
+ indexes = [
141
+ models.Index(
142
+ fields=["status", "created_at"], name="runs_status_created_at_idx"
143
+ )
144
+ ]
145
+ constraints = [
146
+ models.UniqueConstraint(
147
+ fields=["job", "scheduled_for"],
148
+ name="unique_job_scheduled_run",
149
+ ),
150
+ ]
151
+
152
+ @property
153
+ def repository(self) -> Repository:
154
+ return self.task.repository
155
+
156
+ @cached_property
157
+ def inputs(self) -> RunInput:
158
+ return RunInput.model_validate(self.input_snapshot)
159
+
160
+ @cached_property
161
+ def collected(self) -> RunOutputs | None:
162
+ """What the sandbox left behind, or None when nothing was collected."""
163
+ return RunOutputs.model_validate(self.outputs) if self.outputs else None
164
+
165
+ @cached_property
166
+ def plan(self) -> ExecutionPlan:
167
+ return ExecutionPlan.model_validate(self.execution_plan)
168
+
169
+ @property
170
+ def workspace_id(self) -> int:
171
+ return self.task.repository.workspace_id
172
+
173
+ @property
174
+ def job_key(self) -> str:
175
+ """The job's key, or "" for an ad hoc task."""
176
+ return self.plan.job.key if self.job_id is not None else ""
177
+
178
+ @property
179
+ def display_name(self) -> str:
180
+ return (
181
+ self.job.name
182
+ if self.job is not None
183
+ else self.inputs.name or "Ad hoc skill"
184
+ )
185
+
186
+ def __str__(self) -> str:
187
+ return f"{self.display_name} at {self.scheduled_for:%Y-%m-%d %H:%M}"
188
+
189
+
190
+ class RunEvent(models.Model):
191
+ run = models.ForeignKey(Run, on_delete=models.CASCADE, related_name="events")
192
+ level = models.CharField(max_length=16, default="info")
193
+ message = models.TextField()
194
+ metadata = models.JSONField(default=dict, blank=True)
195
+ created_at = models.DateTimeField(auto_now_add=True)
196
+
197
+ class Meta:
198
+ ordering = ["created_at"]
199
+
200
+ def __str__(self) -> str:
201
+ return f"{self.run_id}: {self.message[:80]}"
202
+
203
+
204
+ class RunDelivery(models.Model):
205
+ """One place a finished run's report is sent, kept so a failure can be retried.
206
+
207
+ A retry sends the stored report again. It never runs the agent again.
208
+ """
209
+
210
+ class Kind(models.TextChoices):
211
+ NOTIFICATION = "notification", "Notification"
212
+ PULL_REQUEST = "pull_request", "Pull request"
213
+ ISSUES = "issues", "Issues"
214
+
215
+ class Status(models.TextChoices):
216
+ # Not tried yet.
217
+ PENDING = "pending", "Pending"
218
+ # Tried and failed, and another attempt is coming.
219
+ RETRYING = "retrying", "Retrying"
220
+ DELIVERED = "delivered", "Delivered"
221
+ SKIPPED = "skipped", "Skipped"
222
+ FAILED = "failed", "Failed"
223
+
224
+ run = models.ForeignKey(Run, on_delete=models.CASCADE, related_name="deliveries")
225
+ kind = models.CharField(max_length=24, choices=Kind.choices)
226
+ connection = models.ForeignKey(
227
+ Connection,
228
+ on_delete=models.SET_NULL,
229
+ related_name="run_deliveries",
230
+ null=True,
231
+ blank=True,
232
+ )
233
+ # The connection's name when the delivery was made, kept if it is deleted.
234
+ target = models.CharField(max_length=200)
235
+ status = models.CharField(
236
+ max_length=16, choices=Status.choices, default=Status.PENDING
237
+ )
238
+ attempts = models.PositiveIntegerField(default=0)
239
+ status_code = models.IntegerField(null=True, blank=True)
240
+ message = models.TextField(blank=True)
241
+ # Where the delivered result lives, such as the draft pull request.
242
+ external_url = models.URLField(max_length=500, blank=True)
243
+ created_at = models.DateTimeField(auto_now_add=True)
244
+ updated_at = models.DateTimeField(auto_now=True)
245
+
246
+ @property
247
+ def event_name(self) -> str:
248
+ """How the run's events name this delivery."""
249
+ if self.kind == self.Kind.NOTIFICATION:
250
+ return f"Notification to {self.target}"
251
+ return self.get_kind_display()
252
+
253
+ class Meta:
254
+ ordering = ["created_at", "pk"]
255
+ constraints = [
256
+ models.UniqueConstraint(
257
+ fields=["run", "kind", "connection"],
258
+ name="unique_run_delivery_target",
259
+ ),
260
+ models.UniqueConstraint(
261
+ fields=["run"],
262
+ condition=models.Q(kind="pull_request"),
263
+ name="unique_run_pull_request",
264
+ ),
265
+ models.UniqueConstraint(
266
+ fields=["run"],
267
+ condition=models.Q(kind="issues"),
268
+ name="unique_run_issues",
269
+ ),
270
+ ]
271
+
272
+ def __str__(self) -> str:
273
+ return f"{self.run_id}: {self.kind} to {self.target} {self.status}"
274
+
275
+
276
+ class RunIssue(models.Model):
277
+ """An issue the agent proposed. SecondShift opens it after the run.
278
+
279
+ Opened issues keep their number, so a retry opens only the rest.
280
+ """
281
+
282
+ run = models.ForeignKey(Run, on_delete=models.CASCADE, related_name="issues")
283
+ # The model's tool call. A replayed call finds its proposal instead of
284
+ # adding it again.
285
+ call_id = models.CharField(max_length=128)
286
+ title = models.CharField(max_length=256)
287
+ body = models.TextField(blank=True)
288
+ number = models.PositiveIntegerField(null=True, blank=True)
289
+ url = models.URLField(max_length=500, blank=True)
290
+ # An open issue already had this title, so none was opened.
291
+ already_open = models.BooleanField(default=False)
292
+ created_at = models.DateTimeField(auto_now_add=True)
293
+
294
+ class Meta:
295
+ ordering = ["created_at", "pk"]
296
+ constraints = [
297
+ models.UniqueConstraint(
298
+ fields=["run", "call_id"], name="unique_run_issue_call"
299
+ ),
300
+ ]
301
+
302
+ def __str__(self) -> str:
303
+ return f"{self.run_id}: {self.title}"
304
+
305
+
306
+ class RunAccessNeed(models.Model):
307
+ """Access the agent said it needed and this run did not have.
308
+
309
+ Only a record for people to read. It never grants anything.
310
+ """
311
+
312
+ run = models.ForeignKey(Run, on_delete=models.CASCADE, related_name="access_needs")
313
+ # The first tool call that named the service. A replayed call finds it.
314
+ call_id = models.CharField(max_length=128)
315
+ # As the agent named it, such as "Linear" or "linear".
316
+ service = models.CharField(max_length=100)
317
+ tools = models.JSONField(default=list)
318
+ reason = models.TextField(blank=True)
319
+ created_at = models.DateTimeField(auto_now_add=True)
320
+
321
+ class Meta:
322
+ ordering = ["created_at", "pk"]
323
+ constraints = [
324
+ models.UniqueConstraint(
325
+ fields=["run", "call_id"], name="unique_run_access_need_call"
326
+ ),
327
+ ]
328
+
329
+ def __str__(self) -> str:
330
+ return f"{self.run_id}: {self.service}"
331
+
332
+
333
+ class AgentLease(models.Model):
334
+ """One active agent process, with a revocable run-scoped broker token."""
335
+
336
+ run = models.OneToOneField(Run, on_delete=models.CASCADE)
337
+ token_hash = models.CharField(max_length=64, unique=True)
338
+ session_id = models.CharField(max_length=100)
339
+ active = models.BooleanField(default=True)
340
+ tool_calls = models.PositiveIntegerField(default=0)
File without changes
@@ -0,0 +1,15 @@
1
+ """Admission policy for standalone tasks, shared by dispatch and execution."""
2
+
3
+ from secondshift.repositories.models import Repository
4
+ from secondshift.runs.models import Run
5
+ from secondshift.workspaces.access import Action, role_allows
6
+ from secondshift.workspaces.dao import active_member_role
7
+
8
+
9
+ def task_admission_problem(run: Run, repository: Repository) -> str:
10
+ if not repository.accepts_runs:
11
+ return "The repository was disabled or removed before this task started."
12
+ role = active_member_role(repository.workspace_id, run.task.submitted_by_id)
13
+ if not role_allows(role, Action.RUN_JOBS):
14
+ return "The person who submitted this task can no longer run tasks in this workspace."
15
+ return ""
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import import_module
4
+
5
+ from dbos import DBOS, DBOSClient, DBOSConfig
6
+ from django.conf import settings
7
+
8
+
9
+ def dbos_config() -> DBOSConfig:
10
+ return {
11
+ "name": settings.DBOS_APPLICATION_NAME,
12
+ "application_version": settings.DBOS_APPLICATION_VERSION,
13
+ "system_database_url": settings.DBOS_SYSTEM_DATABASE_URL,
14
+ }
15
+
16
+
17
+ def new_dbos_client() -> DBOSClient:
18
+ return DBOSClient(
19
+ system_database_url=settings.DBOS_SYSTEM_DATABASE_URL,
20
+ application_name=settings.DBOS_APPLICATION_NAME,
21
+ )
22
+
23
+
24
+ def launch_dbos_worker() -> None:
25
+ # Importing registers decorated workflows before DBOS starts recovery.
26
+ import_module("secondshift.runs.orchestration.workflows")
27
+
28
+ if settings.DBOS_WORKER_CONCURRENCY < 1 or settings.DBOS_GLOBAL_CONCURRENCY < 0:
29
+ raise ValueError(
30
+ "Run concurrency must be positive; the global limit may be zero."
31
+ )
32
+ global_limit = settings.DBOS_GLOBAL_CONCURRENCY or None
33
+ worker_limit = settings.DBOS_WORKER_CONCURRENCY
34
+ if global_limit is not None:
35
+ worker_limit = min(worker_limit, global_limit)
36
+ DBOS(config=dbos_config())
37
+ DBOS.listen_queues([settings.DBOS_QUEUE_NAME])
38
+ DBOS.launch()
39
+ DBOS.register_queue(
40
+ settings.DBOS_QUEUE_NAME,
41
+ worker_concurrency=worker_limit,
42
+ global_concurrency=global_limit,
43
+ )
@@ -0,0 +1,130 @@
1
+ import logging
2
+
3
+ from dbos import EnqueueOptions
4
+ from django.conf import settings
5
+ from django.db import transaction
6
+ from django.utils import timezone
7
+
8
+ from secondshift.config.constants import EXECUTE_RUN_WORKFLOW
9
+ from secondshift.repositories.dao import lock_repository
10
+ from secondshift.runs.dao import (
11
+ add_event,
12
+ add_event_once,
13
+ first_due_task,
14
+ has_dispatched_task,
15
+ repository_task_run,
16
+ waiting_tasks,
17
+ )
18
+ from secondshift.runs.models import Run, RunFailure
19
+ from secondshift.runs.orchestration.admission import task_admission_problem
20
+ from secondshift.runs.orchestration.dbos_runtime import new_dbos_client
21
+ from secondshift.runs.orchestration.reconciliation import (
22
+ WorkflowStatus,
23
+ mark_run_terminal,
24
+ )
25
+ from secondshift.runs.transitions import finish_run
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ RUN_ENQUEUE_FAILED = "The durable workflow could not be enqueued."
30
+
31
+
32
+ class RunEnqueueFailed(Exception):
33
+ """The workflow could not be queued and the run was marked failed."""
34
+
35
+ def __init__(self, run: Run) -> None:
36
+ super().__init__(RUN_ENQUEUE_FAILED)
37
+ self.run = run
38
+
39
+
40
+ def enqueue_run(run: Run) -> str:
41
+ if run.job_id is None:
42
+ return enqueue_task(run)
43
+ try:
44
+ workflow_id = queue_workflow(run)
45
+ except Exception as error:
46
+ add_event(run.pk, RUN_ENQUEUE_FAILED, level="error")
47
+ failed = finish_run(
48
+ run.pk,
49
+ Run.Status.FAILED,
50
+ RUN_ENQUEUE_FAILED,
51
+ only_from={Run.Status.QUEUED},
52
+ failure=RunFailure.NOT_QUEUED,
53
+ )
54
+ if failed is not None:
55
+ raise RunEnqueueFailed(failed) from error
56
+ raise
57
+ return record_workflow(run, workflow_id)
58
+
59
+
60
+ def queue_workflow(run: Run) -> str:
61
+ """Hand the run to DBOS. Raises when DBOS refuses."""
62
+ workflow_id = f"secondshift-run-{run.pk}"
63
+ options: EnqueueOptions = {
64
+ "workflow_name": EXECUTE_RUN_WORKFLOW,
65
+ "queue_name": settings.DBOS_QUEUE_NAME,
66
+ "workflow_id": workflow_id,
67
+ "application_name": settings.DBOS_APPLICATION_NAME,
68
+ }
69
+ client = new_dbos_client()
70
+ try:
71
+ workflow_id = client.enqueue(options, run.pk).get_workflow_id()
72
+ finally:
73
+ try:
74
+ client.destroy()
75
+ except Exception:
76
+ logger.warning("Could not close execution queue client for run %s", run.pk)
77
+ return workflow_id
78
+
79
+
80
+ def record_workflow(run: Run, workflow_id: str) -> str:
81
+ run.dbos_workflow_id = workflow_id
82
+ run.save(update_fields=["dbos_workflow_id"])
83
+ return workflow_id
84
+
85
+
86
+ @transaction.atomic
87
+ def enqueue_task(run: Run) -> str:
88
+ """Hand the oldest eligible task to DBOS, once per repository at a time.
89
+
90
+ The repository lock covers the external handoff and its acknowledgement.
91
+ It serializes submissions, worker retries and queued cancellations.
92
+ """
93
+ repository = lock_repository(run.task.repository_id)
94
+ run = repository_task_run(repository.pk, run.pk)
95
+ if run.status != Run.Status.QUEUED:
96
+ return ""
97
+ if run.dbos_workflow_id:
98
+ return run.dbos_workflow_id
99
+ if run.scheduled_for > timezone.now():
100
+ return ""
101
+ if problem := task_admission_problem(run, repository):
102
+ mark_run_terminal(
103
+ run.pk,
104
+ Run.Status.FAILED,
105
+ problem,
106
+ workflow_status=WorkflowStatus.NOT_ADMITTED,
107
+ )
108
+ return ""
109
+ if has_dispatched_task(repository.pk):
110
+ return ""
111
+ first = first_due_task(repository.pk, timezone.now())
112
+ if first is None or first.pk != run.pk:
113
+ return ""
114
+ try:
115
+ return record_workflow(run, queue_workflow(run))
116
+ except Exception:
117
+ # The Run is the durable outbox. Retry with the same workflow ID,
118
+ # including after a crash between DBOS accepting and our save.
119
+ add_event_once(
120
+ run.pk,
121
+ "Waiting for the execution queue. The worker will retry automatically.",
122
+ "warning",
123
+ )
124
+ return ""
125
+
126
+
127
+ def dispatch_waiting_tasks() -> int:
128
+ """Recover committed submissions and advance the queue on each worker pass."""
129
+ waiting = waiting_tasks(timezone.now(), 500)
130
+ return sum(bool(enqueue_task(run)) for run in waiting)
@@ -0,0 +1,39 @@
1
+ """Work the worker repeats on a timer, each with its own interval."""
2
+
3
+ import logging
4
+ import time
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def always() -> bool:
12
+ return True
13
+
14
+
15
+ @dataclass
16
+ class PeriodicTask:
17
+ name: str
18
+ # Seconds between runs. 0 runs it on every pass of the worker loop.
19
+ every: float
20
+ run: Callable[[], object]
21
+ # Checked each pass, so a setting can turn the task off.
22
+ enabled: Callable[[], bool] = always
23
+ clock: Callable[[], float] = time.monotonic
24
+ last_run: float | None = field(default=None, init=False)
25
+
26
+ def due(self) -> bool:
27
+ if not self.enabled():
28
+ return False
29
+ return self.last_run is None or self.clock() - self.last_run >= self.every
30
+
31
+ def run_if_due(self) -> None:
32
+ """Run when due. A failure is logged and the worker carries on."""
33
+ if not self.due():
34
+ return
35
+ self.last_run = self.clock()
36
+ try:
37
+ self.run()
38
+ except Exception:
39
+ logger.exception("periodic task %r failed", self.name)
@@ -0,0 +1,98 @@
1
+ from enum import StrEnum
2
+ from typing import Literal
3
+
4
+ from dbos import DBOSClient
5
+ from django.conf import settings
6
+ from django.db import transaction
7
+
8
+ from secondshift.runs.dao import active_workflows, add_event
9
+ from secondshift.runs.models import Run, RunFailure
10
+ from secondshift.runs.orchestration.dbos_runtime import new_dbos_client
11
+ from secondshift.runs.transitions import finish_run
12
+
13
+
14
+ class WorkflowStatus(StrEnum):
15
+ """DBOS workflow statuses, plus the one recorded before DBOS starts a run."""
16
+
17
+ ERROR = "ERROR"
18
+ MAX_RECOVERY_ATTEMPTS_EXCEEDED = "MAX_RECOVERY_ATTEMPTS_EXCEEDED"
19
+ CANCELLED = "CANCELLED"
20
+ SUCCESS = "SUCCESS"
21
+ NOT_ADMITTED = "NOT_ADMITTED"
22
+
23
+
24
+ TERMINAL_WORKFLOW_STATUSES = {
25
+ WorkflowStatus.ERROR,
26
+ WorkflowStatus.MAX_RECOVERY_ATTEMPTS_EXCEEDED,
27
+ WorkflowStatus.CANCELLED,
28
+ WorkflowStatus.SUCCESS,
29
+ }
30
+
31
+
32
+ @transaction.atomic
33
+ def mark_run_terminal(
34
+ run_id: int,
35
+ status: Literal["failed", "canceled"],
36
+ summary: str,
37
+ *,
38
+ workflow_status: WorkflowStatus,
39
+ metadata: dict[str, object] | None = None,
40
+ failure: RunFailure = RunFailure.OTHER,
41
+ reason: str = "",
42
+ ) -> bool:
43
+ run = finish_run(run_id, status, summary, failure=failure, reason=reason)
44
+ if run is None:
45
+ return False
46
+ add_event(
47
+ run.pk,
48
+ summary,
49
+ level="error" if status == Run.Status.FAILED else "info",
50
+ metadata={**(metadata or {}), "workflow_status": workflow_status},
51
+ )
52
+ return True
53
+
54
+
55
+ def reconcile_terminal_workflows(client: DBOSClient | None = None) -> int:
56
+ pending_runs = active_workflows(500)
57
+ if not pending_runs:
58
+ return 0
59
+
60
+ owns_client = client is None
61
+ client = client or new_dbos_client()
62
+ try:
63
+ workflows = client.list_workflows(
64
+ workflow_ids=[workflow_id for _, workflow_id in pending_runs],
65
+ application_name=settings.DBOS_APPLICATION_NAME,
66
+ load_input=False,
67
+ load_output=False,
68
+ )
69
+ finally:
70
+ if owns_client:
71
+ client.destroy()
72
+
73
+ run_ids = {workflow_id: run_id for run_id, workflow_id in pending_runs}
74
+ reconciled = 0
75
+ for workflow in workflows:
76
+ if workflow.status not in TERMINAL_WORKFLOW_STATUSES:
77
+ continue
78
+ run_id = run_ids.get(workflow.workflow_id)
79
+ if run_id is None:
80
+ continue
81
+ if workflow.status == WorkflowStatus.CANCELLED:
82
+ status = Run.Status.CANCELED
83
+ summary = "The durable workflow was canceled before the run completed."
84
+ elif workflow.status == WorkflowStatus.SUCCESS:
85
+ status = Run.Status.FAILED
86
+ summary = "The workflow completed without persisting a terminal run result."
87
+ else:
88
+ status = Run.Status.FAILED
89
+ summary = "The durable workflow failed before the run completed."
90
+ reconciled += int(
91
+ mark_run_terminal(
92
+ run_id,
93
+ status,
94
+ summary,
95
+ workflow_status=workflow.status,
96
+ )
97
+ )
98
+ return reconciled