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.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- 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
|