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,80 @@
1
+ """Delivering a finished run's results again, without running the agent again."""
2
+
3
+ import uuid
4
+
5
+ from dbos import EnqueueOptions
6
+ from django.conf import settings
7
+ from django.db import transaction
8
+
9
+ from secondshift.config.constants import (
10
+ RETRY_ISSUES_WORKFLOW,
11
+ RETRY_NOTIFICATION_WORKFLOW,
12
+ RETRY_PULL_REQUEST_WORKFLOW,
13
+ )
14
+ from secondshift.runs.dao import add_event, lock_delivery
15
+ from secondshift.runs.models import Run, RunDelivery
16
+ from secondshift.runs.orchestration.dbos_runtime import new_dbos_client
17
+ from secondshift.runs.services import RunRefused
18
+
19
+
20
+ class DeliveryQueueUnavailable(Exception):
21
+ pass
22
+
23
+
24
+ def retryable(delivery: RunDelivery) -> bool:
25
+ # Pull requests and issues need no connection. The repository's own
26
+ # GitHub access sends them.
27
+ return delivery.status == RunDelivery.Status.FAILED and (
28
+ delivery.kind in {RunDelivery.Kind.PULL_REQUEST, RunDelivery.Kind.ISSUES}
29
+ or delivery.connection_id is not None
30
+ )
31
+
32
+
33
+ @transaction.atomic
34
+ def retry_delivery(run: Run, delivery_id: int) -> RunDelivery:
35
+ """Queue a failed delivery again. Authorization is checked when it is sent."""
36
+ delivery = lock_delivery(run, delivery_id)
37
+ if delivery.status != RunDelivery.Status.FAILED:
38
+ raise RunRefused("Only a failed delivery can be retried.")
39
+ if not retryable(delivery):
40
+ raise RunRefused("The connection for this delivery was removed.")
41
+ delivery.status = RunDelivery.Status.RETRYING
42
+ delivery.message = "Waiting to retry."
43
+ delivery.save(update_fields=["status", "message", "updated_at"])
44
+ # Enqueued inside the transaction, so a queue outage leaves it failed.
45
+ if delivery.kind == RunDelivery.Kind.PULL_REQUEST:
46
+ add_event(run.pk, "Retrying the pull request.")
47
+ enqueue_retry(RETRY_PULL_REQUEST_WORKFLOW, f"pull-request-{run.pk}", run.pk)
48
+ elif delivery.kind == RunDelivery.Kind.ISSUES:
49
+ add_event(run.pk, "Retrying the issues.")
50
+ enqueue_retry(RETRY_ISSUES_WORKFLOW, f"issues-{run.pk}", run.pk)
51
+ else:
52
+ add_event(run.pk, f"Retrying the notification to {delivery.target}.")
53
+ enqueue_retry(
54
+ RETRY_NOTIFICATION_WORKFLOW,
55
+ f"{run.pk}-{delivery.connection_id}",
56
+ run.pk,
57
+ delivery.connection_id,
58
+ )
59
+ return delivery
60
+
61
+
62
+ def enqueue_retry(workflow: str, name: str, *args: int) -> str:
63
+ suffix = uuid.uuid4().hex[:12]
64
+ options: EnqueueOptions = {
65
+ "workflow_name": workflow,
66
+ "queue_name": settings.DBOS_QUEUE_NAME,
67
+ # Each retry is its own workflow. A reused ID would return the old result.
68
+ "workflow_id": f"secondshift-delivery-{name}-{suffix}",
69
+ "application_name": settings.DBOS_APPLICATION_NAME,
70
+ }
71
+ try:
72
+ client = new_dbos_client()
73
+ except Exception as error:
74
+ raise DeliveryQueueUnavailable from error
75
+ try:
76
+ return client.enqueue(options, *args).get_workflow_id()
77
+ except Exception as error:
78
+ raise DeliveryQueueUnavailable from error
79
+ finally:
80
+ client.destroy()
File without changes
@@ -0,0 +1,89 @@
1
+ """Let the agent say what access it needed and this run did not have.
2
+
3
+ A skill from another repository often expects services nobody connected. The
4
+ agent cannot see tools it was not given, so it reports them here instead of
5
+ quietly doing less. The run page lists them. Reporting never grants anything.
6
+ """
7
+
8
+ from typing import Annotated, Any
9
+
10
+ from pydantic import Field
11
+ from pydantic_ai import ModelRetry, RunContext, Tool
12
+
13
+ from secondshift.runs.dao import (
14
+ access_need_for_call,
15
+ create_access_need,
16
+ run_access_needs,
17
+ update_access_need_tools,
18
+ )
19
+ from secondshift.runs.execution.workspace_tools import WorkspaceToolDeps
20
+
21
+ TOOL_NAME = "report_missing_access"
22
+ # Enough for a skill that names several services, without filling the page.
23
+ MAX_SERVICES = 20
24
+ MAX_TOOLS = 30
25
+
26
+ ServiceArgument = Annotated[str, Field(min_length=1, max_length=100)]
27
+ ToolsArgument = Annotated[
28
+ list[Annotated[str, Field(min_length=1, max_length=100)]],
29
+ Field(max_length=MAX_TOOLS),
30
+ ]
31
+ ReasonArgument = Annotated[str, Field(max_length=1000)]
32
+
33
+ ACCESS_NOTE = f"""If the task needs a service, tool or credential this run does not have,
34
+ such as an MCP server or API the skill names, call {TOOL_NAME} once for each
35
+ service, then go on with what you can do. Do not try to reach the service
36
+ another way. Name the missing access in your report too."""
37
+
38
+
39
+ def report_missing_access(
40
+ ctx: RunContext[WorkspaceToolDeps],
41
+ service: ServiceArgument,
42
+ tools: ToolsArgument | None = None,
43
+ reason: ReasonArgument = "",
44
+ ) -> dict[str, Any]:
45
+ """Record a service or tool this task needs and this run was not given.
46
+
47
+ Name the service, such as linear or sentry, the tools needed from it, such
48
+ as create_issue, and why. People grant access after the run. Nothing is
49
+ granted now.
50
+ """
51
+ if not ctx.tool_call_id:
52
+ raise RuntimeError("The model tool call has no stable identifier.")
53
+ service = " ".join(service.split())
54
+ if not service:
55
+ raise ModelRetry("Name the service.")
56
+ names = sorted({" ".join(tool.split()) for tool in tools or []} - {""})
57
+ run_id = ctx.deps.run_id
58
+ if access_need_for_call(run_id, ctx.tool_call_id):
59
+ return recorded()
60
+ needs = run_access_needs(run_id)
61
+ same = next(
62
+ (need for need in needs if need.service.casefold() == service.casefold()),
63
+ None,
64
+ )
65
+ if same is not None:
66
+ merged = sorted(set(same.tools) | set(names))[:MAX_TOOLS]
67
+ if merged != same.tools:
68
+ update_access_need_tools(same, merged)
69
+ return recorded()
70
+ if len(needs) >= MAX_SERVICES:
71
+ return {
72
+ "recorded": False,
73
+ "note": f"This run already reported {MAX_SERVICES} services. "
74
+ "Name the rest in the report.",
75
+ }
76
+ create_access_need(run_id, ctx.tool_call_id, service, names, reason.strip())
77
+ return recorded()
78
+
79
+
80
+ def recorded() -> dict[str, Any]:
81
+ return {
82
+ "recorded": True,
83
+ "note": "Recorded for people to review after the run. It is not "
84
+ "available in this run. Continue without it.",
85
+ }
86
+
87
+
88
+ def access_tool() -> Tool[WorkspaceToolDeps]:
89
+ return Tool(report_missing_access, name=TOOL_NAME)
@@ -0,0 +1,91 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+ from asgiref.sync import sync_to_async
6
+ from pydantic_ai import Agent, UsageLimits
7
+ from pydantic_ai.capabilities import ResolveModelId
8
+ from pydantic_ai.durable_exec.dbos import DBOSDurability
9
+ from pydantic_ai.models import Model, ModelResolutionContext
10
+
11
+ from secondshift.runs.dao import get_run
12
+ from secondshift.runs.execution.budgets import FinishWithinBudget
13
+ from secondshift.runs.execution.deadlines import check_deadline
14
+ from secondshift.runs.execution.model_gateway import (
15
+ issue_model_gateway_access,
16
+ pydantic_model,
17
+ )
18
+
19
+ RUN_MODEL_ID = "secondshift-run-model"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AgentLimits:
24
+ max_turns: int
25
+ max_tool_calls: int
26
+ max_tool_result_chars: int = 65_536
27
+
28
+ def __post_init__(self) -> None:
29
+ if self.max_turns < 1:
30
+ raise ValueError("max_turns must be positive")
31
+ if self.max_tool_calls < 0:
32
+ raise ValueError("max_tool_calls cannot be negative")
33
+ if self.max_tool_result_chars < 1:
34
+ raise ValueError("max_tool_result_chars must be positive")
35
+
36
+ def usage_limits(self) -> UsageLimits:
37
+ """Translate server policy to Pydantic AI's native run limits."""
38
+ return UsageLimits(
39
+ request_limit=self.max_turns,
40
+ tool_calls_limit=self.max_tool_calls,
41
+ )
42
+
43
+
44
+ async def resolve_run_model(
45
+ context: ModelResolutionContext[Any], model_id: str
46
+ ) -> Model | None:
47
+ """Build gateway access inside the durable model-request step.
48
+
49
+ Only the run ID crosses the DBOS boundary. The short-lived gateway token is
50
+ created and consumed inside the step and is never serialized into workflow
51
+ inputs, outputs, or agent dependencies.
52
+ """
53
+ if model_id != RUN_MODEL_ID:
54
+ return None
55
+ check_deadline(context.deps.deadline)
56
+ # Pydantic AI resolves models inside its event loop, where Django refuses
57
+ # synchronous database access.
58
+ return await sync_to_async(run_model)(int(context.deps.run_id))
59
+
60
+
61
+ def run_model(run_id: int) -> Model:
62
+ run = get_run(run_id, "job")
63
+ return pydantic_model(issue_model_gateway_access(run))
64
+
65
+
66
+ def build_agent(
67
+ *,
68
+ name: str = "secondshift",
69
+ instructions: str,
70
+ tools: Sequence[Any] = (),
71
+ deps_type: type[Any] = object,
72
+ ) -> Agent[Any, str]:
73
+ """Build the durable orchestrator-side agent for one run.
74
+
75
+ Tools supplied here are server-built wrappers around the runner or capability
76
+ broker. Repository content cannot register tools or change durability.
77
+ """
78
+ return Agent(
79
+ model=RUN_MODEL_ID,
80
+ name=name,
81
+ instructions=instructions,
82
+ deps_type=deps_type,
83
+ tools=tools,
84
+ defer_model_check=True,
85
+ capabilities=[
86
+ ResolveModelId(resolve_run_model),
87
+ FinishWithinBudget(),
88
+ # Durable steps are named by the agent, so each agent needs its own.
89
+ DBOSDurability(name=name),
90
+ ],
91
+ )
@@ -0,0 +1,198 @@
1
+ """Stateless MCP transport over the existing, run-scoped tool policy."""
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any
6
+
7
+ from django.db import transaction
8
+ from django.http import HttpRequest, HttpResponse, JsonResponse
9
+ from django.views.decorators.csrf import csrf_exempt
10
+ from pydantic_ai import ModelRetry, RunContext, RunUsage, Tool, ToolFailed
11
+
12
+ from secondshift.connections.agents import usable_agent_connection
13
+ from secondshift.connections.dao import find_agent_connection
14
+ from secondshift.connections.models import AgentConnection
15
+ from secondshift.runs.artifacts import run_payload
16
+ from secondshift.runs.dao import active_lease, get_run, lock_lease
17
+ from secondshift.runs.execution.access_needs import access_tool
18
+ from secondshift.runs.execution.deadlines import check_deadline, run_deadline
19
+ from secondshift.runs.execution.external_tools import external_tools
20
+ from secondshift.runs.execution.issues import issue_limit, issue_tool
21
+ from secondshift.runs.execution.payload import payload_tool
22
+ from secondshift.runs.execution.workspace_tools import (
23
+ MODEL_TOOL_NAMES,
24
+ WorkspaceToolDeps,
25
+ workspace_tools,
26
+ )
27
+ from secondshift.runs.models import AgentLease, Run
28
+
29
+ # Streamable HTTP MCP versions the broker speaks, newest first.
30
+ PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
31
+
32
+
33
+ class AgentAccessError(ValueError):
34
+ pass
35
+
36
+
37
+ class MethodNotFound(ValueError):
38
+ """Clients probe newer methods first and fall back when they are missing."""
39
+
40
+
41
+ def run_connection(run: Run) -> AgentConnection:
42
+ plan = run.plan.execution
43
+ connection = find_agent_connection(
44
+ plan.connection_id,
45
+ run.workspace_id,
46
+ plan.owner_id,
47
+ plan.credential_key,
48
+ plan.backend,
49
+ )
50
+ if connection is None or not usable_agent_connection(connection, run.workspace_id):
51
+ raise AgentAccessError(
52
+ "The agent login is no longer available. Reconnect and start a new run."
53
+ )
54
+ return connection
55
+
56
+
57
+ def lease_run(lease: AgentLease) -> Run:
58
+ run = get_run(lease.run_id, "job")
59
+ if not lease.active or run.status != Run.Status.RUNNING:
60
+ raise AgentAccessError("This agent run is no longer active.")
61
+ check_deadline(run_deadline(run))
62
+ run_connection(run)
63
+ return run
64
+
65
+
66
+ def broker_tools(run: Run) -> dict[str, Tool[WorkspaceToolDeps]]:
67
+ allowed = set(run.plan.agent.tools)
68
+ local = {MODEL_TOOL_NAMES[name] for name in allowed if name in MODEL_TOOL_NAMES}
69
+ tools = [tool for tool in workspace_tools() if tool.name in local]
70
+ if run_payload(run):
71
+ tools.append(payload_tool())
72
+ if issue_limit(run.plan):
73
+ tools.append(issue_tool())
74
+ tools.append(access_tool())
75
+ return {tool.name: tool for tool in (*tools, *external_tools(run))}
76
+
77
+
78
+ def tool_result(
79
+ lease: AgentLease, name: str, arguments: dict[str, Any]
80
+ ) -> dict[str, Any]:
81
+ with transaction.atomic():
82
+ lease = lock_lease(lease.pk)
83
+ run = lease_run(lease)
84
+ if lease.tool_calls >= run.plan.agent.limits.max_tool_calls:
85
+ raise AgentAccessError("This run has used its tool call budget.")
86
+ # Resolve and validate before counting the call.
87
+ tool = broker_tools(run).get(name)
88
+ if tool is None:
89
+ raise AgentAccessError("This tool is not approved for the run.")
90
+ validated = tool.function_schema.validator.validate_python(arguments)
91
+ lease.tool_calls += 1
92
+ lease.save(update_fields=["tool_calls"])
93
+ # The broker numbers calls itself. Clients restart their request IDs when
94
+ # they reconnect, so those IDs cannot tell two calls apart.
95
+ call_id = f"agent-{lease.pk}-{lease.tool_calls}"
96
+ deps = WorkspaceToolDeps(
97
+ run_id=run.pk,
98
+ session_id=lease.session_id,
99
+ max_output_chars=run.plan.agent.limits.max_tool_result_chars,
100
+ deadline=run_deadline(run),
101
+ allowed_tools=run.plan.agent.tools,
102
+ payload=run_payload(run),
103
+ issue_limit=issue_limit(run.plan),
104
+ )
105
+ context = RunContext(deps=deps, model=None, usage=RunUsage(), tool_call_id=call_id)
106
+ try:
107
+ value = tool.function(context, **validated)
108
+ return {
109
+ "content": [{"type": "text", "text": json.dumps(value)}],
110
+ "isError": False,
111
+ }
112
+ except (ModelRetry, ToolFailed) as error:
113
+ # SecondShift wrote these for the agent, for example which argument to fix.
114
+ return {"content": [{"type": "text", "text": error.message}], "isError": True}
115
+ except Exception:
116
+ # Provider exceptions can include request contents. Return no raw errors.
117
+ return {
118
+ "content": [
119
+ {"type": "text", "text": "The approved tool could not complete."}
120
+ ],
121
+ "isError": True,
122
+ }
123
+
124
+
125
+ def mcp_request(lease: AgentLease, message: dict[str, Any]) -> dict[str, Any] | None:
126
+ run = lease_run(lease)
127
+ method = message.get("method")
128
+ if "id" not in message:
129
+ return None
130
+ if method == "initialize":
131
+ requested = (message.get("params") or {}).get("protocolVersion")
132
+ return {
133
+ "protocolVersion": requested
134
+ if requested in PROTOCOL_VERSIONS
135
+ else PROTOCOL_VERSIONS[0],
136
+ "capabilities": {"tools": {}},
137
+ "serverInfo": {"name": "secondshift-run", "version": "1.0"},
138
+ }
139
+ if method == "ping":
140
+ return {}
141
+ if method == "tools/list":
142
+ return {
143
+ "tools": [
144
+ {
145
+ "name": tool.name,
146
+ "description": tool.description or tool.name,
147
+ "inputSchema": tool.function_schema.json_schema,
148
+ }
149
+ for tool in broker_tools(run).values()
150
+ ]
151
+ }
152
+ if method == "tools/call":
153
+ params = message.get("params", {})
154
+ arguments = params.get("arguments", {})
155
+ if not isinstance(arguments, dict):
156
+ raise AgentAccessError("Invalid tool arguments.")
157
+ return tool_result(lease, str(params.get("name", "")), arguments)
158
+ raise MethodNotFound(str(method))
159
+
160
+
161
+ @csrf_exempt
162
+ def agent_mcp(request: HttpRequest) -> HttpResponse:
163
+ if request.method != "POST":
164
+ return HttpResponse(status=405)
165
+ token = request.headers.get("Authorization", "").removeprefix("Bearer ")
166
+ lease = active_lease(hashlib.sha256(token.encode()).hexdigest()) if token else None
167
+ if lease is None:
168
+ return HttpResponse(status=401)
169
+ if len(request.body) > 1024 * 1024:
170
+ return HttpResponse(status=413)
171
+ message: dict[str, Any] = {}
172
+ try:
173
+ message = json.loads(request.body)
174
+ if not isinstance(message, dict):
175
+ return HttpResponse(status=400)
176
+ result = mcp_request(lease, message)
177
+ if "id" not in message:
178
+ return HttpResponse(status=202)
179
+ return JsonResponse({"jsonrpc": "2.0", "id": message["id"], "result": result})
180
+ except MethodNotFound:
181
+ return JsonResponse(
182
+ {
183
+ "jsonrpc": "2.0",
184
+ "id": message.get("id"),
185
+ "error": {"code": -32601, "message": "Method not found."},
186
+ }
187
+ )
188
+ except Exception:
189
+ return JsonResponse(
190
+ {
191
+ "jsonrpc": "2.0",
192
+ "id": message.get("id"),
193
+ "error": {
194
+ "code": -32000,
195
+ "message": "Agent access denied or tool request invalid.",
196
+ },
197
+ }
198
+ )
@@ -0,0 +1,177 @@
1
+ """Run budgets: model requests, tool calls and tokens.
2
+
3
+ The agent sees when it has to finish. On its last allowed model request, or
4
+ once its tool calls are used up, its tools are hidden and it is told to write
5
+ the report with what it has. A run that still runs out keeps what the model
6
+ wrote so far as a partial report. So does a run that times out.
7
+ """
8
+
9
+ import datetime
10
+ from collections import Counter
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from enum import StrEnum
14
+ from typing import Any
15
+
16
+ from pydantic_ai import RunContext
17
+ from pydantic_ai.capabilities import AbstractCapability
18
+ from pydantic_ai.exceptions import ModelHTTPError
19
+ from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPart
20
+ from pydantic_ai.tools import ToolDefinition
21
+ from pydantic_ai.usage import RunUsage
22
+
23
+ from secondshift.config.constants import TOKEN_BUDGET_MESSAGE, AgentBackend
24
+ from secondshift.jobs.plan import ExecutionPlan
25
+ from secondshift.runs.models import RunFailure
26
+ from secondshift.runs.reporting.failures import StageFailure
27
+ from secondshift.workspaces.limits import RunLimit, raise_hint
28
+
29
+ BUDGET_HEADLINE = RunFailure.BUDGET.label
30
+ FINISH_INSTRUCTIONS = (
31
+ "This run has reached its budget, so no tools are available. Write the final "
32
+ "report now from what you have found. Say what you could not check."
33
+ )
34
+ PARTIAL_REPORT_NOTE = (
35
+ "_The run stopped at one of its limits before the agent finished. "
36
+ "This is what it wrote so far._"
37
+ )
38
+
39
+
40
+ class Budget(StrEnum):
41
+ REQUESTS = "requests"
42
+ TOOL_CALLS = "tool_calls"
43
+ TOKENS = "tokens"
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class BudgetLimit:
48
+ label: str
49
+ limit: RunLimit
50
+
51
+
52
+ BUDGET_LIMITS = {
53
+ Budget.REQUESTS: BudgetLimit("model requests", RunLimit.MAX_TURNS),
54
+ Budget.TOOL_CALLS: BudgetLimit("tool calls", RunLimit.MAX_TOOL_CALLS),
55
+ Budget.TOKENS: BudgetLimit("tokens", RunLimit.TOKEN_BUDGET),
56
+ }
57
+
58
+
59
+ def spent_budget(context: RunContext[Any]) -> Budget | None:
60
+ """The budget that leaves the agent no room for another tool round."""
61
+ limits = context.usage_limits
62
+ if limits is None:
63
+ return None
64
+ usage = context.usage
65
+ # The request about to be made is not counted yet.
66
+ if limits.request_limit is not None and usage.requests + 1 >= limits.request_limit:
67
+ return Budget.REQUESTS
68
+ if (
69
+ limits.tool_calls_limit is not None
70
+ and usage.tool_calls >= limits.tool_calls_limit
71
+ ):
72
+ return Budget.TOOL_CALLS
73
+ return None
74
+
75
+
76
+ class FinishWithinBudget(AbstractCapability[Any]):
77
+ """Hide the tools and ask for the report once the budget is spent."""
78
+
79
+ async def prepare_tools(
80
+ self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
81
+ ) -> list[ToolDefinition]:
82
+ return [] if spent_budget(ctx) else tool_defs
83
+
84
+ def get_instructions(self) -> Any:
85
+ def instructions(ctx: RunContext[Any]) -> str:
86
+ return FINISH_INSTRUCTIONS if spent_budget(ctx) else ""
87
+
88
+ return instructions
89
+
90
+
91
+ def reached_budget(
92
+ usage: RunUsage, max_turns: int, max_tool_calls: int
93
+ ) -> Budget | None:
94
+ """The budget a finished run used in full, if any."""
95
+ if usage.requests >= max_turns:
96
+ return Budget.REQUESTS
97
+ if usage.tool_calls >= max_tool_calls:
98
+ return Budget.TOOL_CALLS
99
+ return None
100
+
101
+
102
+ def exhausted_budget(messages: Sequence[ModelMessage], max_turns: int) -> Budget:
103
+ """Which native limit stopped the run, from the messages it produced."""
104
+ responses = sum(isinstance(message, ModelResponse) for message in messages)
105
+ return Budget.REQUESTS if responses >= max_turns else Budget.TOOL_CALLS
106
+
107
+
108
+ def is_token_budget_refusal(error: BaseException) -> bool:
109
+ if not isinstance(error, ModelHTTPError) or error.status_code != 429:
110
+ return False
111
+ return TOKEN_BUDGET_MESSAGE in str(error.body)
112
+
113
+
114
+ def budget_reason(budget: Budget, limit: int) -> str:
115
+ spec = BUDGET_LIMITS[budget]
116
+ return (
117
+ f"Stopped at {limit:,} {spec.label}, the run's limit. "
118
+ f"{raise_hint(spec.limit, limit)}"
119
+ )
120
+
121
+
122
+ def budget_reached_message(budget: Budget) -> str:
123
+ return (
124
+ f"The run used all its {BUDGET_LIMITS[budget].label}, so the agent "
125
+ "wrote its report without further tool calls."
126
+ )
127
+
128
+
129
+ def budget_failure(budget: Budget, limit: int) -> StageFailure:
130
+ reason = budget_reason(budget, limit)
131
+ return StageFailure(RunFailure.BUDGET, reason, f"{BUDGET_HEADLINE}: {reason}")
132
+
133
+
134
+ def partial_report(messages: Sequence[ModelMessage]) -> str:
135
+ """What the model wrote and which tools it called before it was stopped."""
136
+ texts: list[str] = []
137
+ calls: Counter[str] = Counter()
138
+ for message in messages:
139
+ if not isinstance(message, ModelResponse):
140
+ continue
141
+ for part in message.parts:
142
+ if isinstance(part, TextPart) and part.content.strip():
143
+ texts.append(part.content.strip())
144
+ elif isinstance(part, ToolCallPart):
145
+ calls[part.tool_name] += 1
146
+ if not texts and not calls:
147
+ return ""
148
+ sections = [PARTIAL_REPORT_NOTE, *texts]
149
+ if calls:
150
+ sections.append(
151
+ "Tool calls made:\n"
152
+ + "\n".join(f"- `{name}`: {count}" for name, count in calls.items())
153
+ )
154
+ return "\n\n".join(sections)
155
+
156
+
157
+ def limits_note(
158
+ plan: ExecutionPlan, backend: AgentBackend, started_at: datetime.datetime | None
159
+ ) -> str:
160
+ """The prompt lines that tell the agent how much room the run has."""
161
+ minutes = plan.limit(RunLimit.TIMEOUT_SECONDS) // 60
162
+ limits = [
163
+ f"{minutes} minutes",
164
+ f"{plan.limit(RunLimit.MAX_TOOL_CALLS):,} tool calls",
165
+ ]
166
+ if backend.limits_model_calls:
167
+ limits.append(f"{plan.limit(RunLimit.MAX_TURNS):,} model calls")
168
+ start = ""
169
+ if started_at is not None:
170
+ start = started_at.astimezone(datetime.UTC).strftime(
171
+ " It started at %H:%M:%S UTC."
172
+ )
173
+ return (
174
+ f"This run's limits are {', '.join(limits[:-1])} and {limits[-1]}.{start} "
175
+ "Sub-agents' tool calls count toward them. Plan the work to fit, and "
176
+ "keep enough room to write your report."
177
+ )