shikiru-cli 0.2.0__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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
shikiru/store/tasks.py
ADDED
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
"""Task rows: insert, transitions, holds and dependent repointing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
|
+
|
|
8
|
+
from pydantic import TypeAdapter
|
|
9
|
+
|
|
10
|
+
from shikiru.agents.contracts import Rung
|
|
11
|
+
from shikiru.domain.escalation import EscalationStatus
|
|
12
|
+
from shikiru.domain.events import EventKind
|
|
13
|
+
from shikiru.domain.reports import SIBLING_REFERENCE_PREFIX, resolve_dependency
|
|
14
|
+
from shikiru.domain.scheduling import find_dependency_cycle
|
|
15
|
+
from shikiru.domain.tasks import (
|
|
16
|
+
ACTIVE_STATUSES,
|
|
17
|
+
DEFAULT_COMPLEXITY,
|
|
18
|
+
Counters,
|
|
19
|
+
Hold,
|
|
20
|
+
Mode,
|
|
21
|
+
RepoState,
|
|
22
|
+
Role,
|
|
23
|
+
Status,
|
|
24
|
+
StatusReason,
|
|
25
|
+
Step,
|
|
26
|
+
Task,
|
|
27
|
+
TaskOrigin,
|
|
28
|
+
)
|
|
29
|
+
from shikiru.errors import InvariantError, UsageError
|
|
30
|
+
from shikiru.store.database import (
|
|
31
|
+
dump_json,
|
|
32
|
+
format_timestamp,
|
|
33
|
+
load_json,
|
|
34
|
+
next_sequence,
|
|
35
|
+
parse_required_timestamp,
|
|
36
|
+
parse_timestamp,
|
|
37
|
+
require_after_insert,
|
|
38
|
+
transaction,
|
|
39
|
+
)
|
|
40
|
+
from shikiru.store.events import EventLog, fetch_latest_events
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
import sqlite3 # noqa: TID251
|
|
44
|
+
from collections.abc import Sequence
|
|
45
|
+
|
|
46
|
+
from shikiru.clock import Clock
|
|
47
|
+
from shikiru.config.settings import ComplexityName
|
|
48
|
+
from shikiru.domain.commands import Submit
|
|
49
|
+
from shikiru.domain.reports import NewTask
|
|
50
|
+
from shikiru.domain.scheduling import WaitReason
|
|
51
|
+
from shikiru.domain.steps import Transition
|
|
52
|
+
|
|
53
|
+
TASK_ID_PREFIX = "T-"
|
|
54
|
+
TASK_SEQUENCE = "task"
|
|
55
|
+
|
|
56
|
+
_HOLDS_ADAPTER: TypeAdapter[frozenset[Hold]] = TypeAdapter(frozenset[Hold])
|
|
57
|
+
_COUNTERS_ADAPTER: TypeAdapter[Counters] = TypeAdapter(Counters)
|
|
58
|
+
_RUNG_INDEX_ADAPTER: TypeAdapter[dict[Role, int]] = TypeAdapter(dict[Role, int])
|
|
59
|
+
_OVERRIDES_ADAPTER: TypeAdapter[dict[Role, Rung]] = TypeAdapter(dict[Role, Rung])
|
|
60
|
+
_PANES_ADAPTER: TypeAdapter[dict[Role, str]] = TypeAdapter(dict[Role, str])
|
|
61
|
+
_REPO_ADAPTER: TypeAdapter[RepoState] = TypeAdapter(RepoState)
|
|
62
|
+
_BROWSER_CATEGORY_ACTIVE = "active"
|
|
63
|
+
_BROWSER_CATEGORY_WAITING = "waiting"
|
|
64
|
+
_BROWSER_CATEGORY_HISTORY = "history"
|
|
65
|
+
_BROWSER_CATEGORY_ALL = "all"
|
|
66
|
+
_BROWSER_STATUS_FILTERS = frozenset(status.value for status in Status)
|
|
67
|
+
_BROWSER_FILTERS = _BROWSER_STATUS_FILTERS | {
|
|
68
|
+
_BROWSER_CATEGORY_ACTIVE,
|
|
69
|
+
_BROWSER_CATEGORY_WAITING,
|
|
70
|
+
_BROWSER_CATEGORY_HISTORY,
|
|
71
|
+
_BROWSER_CATEGORY_ALL,
|
|
72
|
+
}
|
|
73
|
+
_RELATIONSHIP_LIMIT = 24
|
|
74
|
+
_FAMILY_OUTCOME_ORDER = (
|
|
75
|
+
Status.QUEUED.value,
|
|
76
|
+
Status.RUNNING.value,
|
|
77
|
+
Status.INTEGRATING.value,
|
|
78
|
+
Status.ESCALATED.value,
|
|
79
|
+
Status.DONE.value,
|
|
80
|
+
Status.CANCELLED.value,
|
|
81
|
+
StatusReason.SUPERSEDED.value,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class TaskBrowserPage:
|
|
87
|
+
"""A bounded task page with counts for the chosen filter and current project."""
|
|
88
|
+
|
|
89
|
+
tasks: tuple[Task, ...]
|
|
90
|
+
open_decision_task_ids: frozenset[str]
|
|
91
|
+
matching_count: int
|
|
92
|
+
filtered_count: int
|
|
93
|
+
project_count: int
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class TaskRelationships:
|
|
98
|
+
"""A bounded task family group, separate dependency edges and recorded replacements."""
|
|
99
|
+
|
|
100
|
+
family_parent: Task
|
|
101
|
+
family_root: Task
|
|
102
|
+
family_children: tuple[Task, ...]
|
|
103
|
+
family_child_count: int
|
|
104
|
+
dependencies: tuple[Task, ...]
|
|
105
|
+
dependency_count: int
|
|
106
|
+
blocking_dependencies: tuple[Task, ...]
|
|
107
|
+
blocking_dependency_count: int
|
|
108
|
+
dependents: tuple[Task, ...]
|
|
109
|
+
dependent_count: int
|
|
110
|
+
replacements: tuple[Task, ...]
|
|
111
|
+
replacement_count: int
|
|
112
|
+
family_outcomes: tuple[tuple[str, int], ...]
|
|
113
|
+
page_offset: int
|
|
114
|
+
page_size: int
|
|
115
|
+
has_more: bool
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_TASK_COLUMNS = (
|
|
119
|
+
"id, seq, parent_id, origin, title, request, mode, complexity, complexity_explicit, "
|
|
120
|
+
"priority, status, step, "
|
|
121
|
+
"status_reason, holds, iteration, counters, rung_index, overrides, repo, slot, window_id, "
|
|
122
|
+
"panes, wait_reason, requeued_at, summary, created_at, started_at, finished_at"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _row_to_task(row: sqlite3.Row, depends_on: Sequence[str]) -> Task:
|
|
127
|
+
return Task(
|
|
128
|
+
id=row["id"],
|
|
129
|
+
seq=row["seq"],
|
|
130
|
+
parent_id=row["parent_id"],
|
|
131
|
+
origin=TaskOrigin(row["origin"]),
|
|
132
|
+
title=row["title"],
|
|
133
|
+
request=row["request"],
|
|
134
|
+
mode=Mode(row["mode"]) if row["mode"] is not None else None,
|
|
135
|
+
complexity=row["complexity"] if row["complexity_explicit"] else None,
|
|
136
|
+
priority=row["priority"],
|
|
137
|
+
depends_on=list(depends_on),
|
|
138
|
+
status=Status(row["status"]),
|
|
139
|
+
step=Step(row["step"]) if row["step"] is not None else None,
|
|
140
|
+
status_reason=row["status_reason"],
|
|
141
|
+
holds=load_json(_HOLDS_ADAPTER, row["holds"]),
|
|
142
|
+
iteration=row["iteration"],
|
|
143
|
+
counters=load_json(_COUNTERS_ADAPTER, row["counters"]),
|
|
144
|
+
rung_index=load_json(_RUNG_INDEX_ADAPTER, row["rung_index"]),
|
|
145
|
+
overrides=load_json(_OVERRIDES_ADAPTER, row["overrides"]),
|
|
146
|
+
repo=load_json(_REPO_ADAPTER, row["repo"]) if row["repo"] is not None else None,
|
|
147
|
+
slot=row["slot"],
|
|
148
|
+
window_id=row["window_id"],
|
|
149
|
+
panes=load_json(_PANES_ADAPTER, row["panes"]),
|
|
150
|
+
wait_reason=row["wait_reason"],
|
|
151
|
+
requeued_at=parse_timestamp(row["requeued_at"]),
|
|
152
|
+
summary=row["summary"],
|
|
153
|
+
created_at=parse_required_timestamp(row["created_at"]),
|
|
154
|
+
started_at=parse_timestamp(row["started_at"]),
|
|
155
|
+
finished_at=parse_timestamp(row["finished_at"]),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _fetch_depends_on(
|
|
160
|
+
connection: sqlite3.Connection, task_ids: Sequence[str]
|
|
161
|
+
) -> dict[str, list[str]]:
|
|
162
|
+
deps: dict[str, list[str]] = {task_id: [] for task_id in task_ids}
|
|
163
|
+
if not task_ids:
|
|
164
|
+
return deps
|
|
165
|
+
placeholders = ", ".join("?" for _ in task_ids)
|
|
166
|
+
rows = connection.execute(
|
|
167
|
+
f"SELECT task_id, depends_on FROM task_deps WHERE task_id IN ({placeholders})",
|
|
168
|
+
tuple(task_ids),
|
|
169
|
+
).fetchall()
|
|
170
|
+
for row in rows:
|
|
171
|
+
deps[row["task_id"]].append(row["depends_on"])
|
|
172
|
+
return deps
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _rows_to_tasks(connection: sqlite3.Connection, rows: Sequence[sqlite3.Row]) -> list[Task]:
|
|
176
|
+
deps = _fetch_depends_on(connection, [row["id"] for row in rows])
|
|
177
|
+
return [_row_to_task(row, deps[row["id"]]) for row in rows]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def fetch_task(connection: sqlite3.Connection, task_id: str) -> Task | None:
|
|
181
|
+
"""Read one task by id, or `None` if it doesn't exist."""
|
|
182
|
+
row = connection.execute(
|
|
183
|
+
f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id = ?", (task_id,)
|
|
184
|
+
).fetchone()
|
|
185
|
+
if row is None:
|
|
186
|
+
return None
|
|
187
|
+
return _row_to_task(row, _fetch_depends_on(connection, [task_id])[task_id])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def fetch_tasks(
|
|
191
|
+
connection: sqlite3.Connection, *, statuses: Sequence[Status] | None = None
|
|
192
|
+
) -> list[Task]:
|
|
193
|
+
"""Read every task, optionally restricted to `statuses`, ordered by `seq`."""
|
|
194
|
+
if statuses is None:
|
|
195
|
+
rows = connection.execute(f"SELECT {_TASK_COLUMNS} FROM tasks ORDER BY seq").fetchall()
|
|
196
|
+
else:
|
|
197
|
+
placeholders = ", ".join("?" for _ in statuses)
|
|
198
|
+
rows = connection.execute(
|
|
199
|
+
f"SELECT {_TASK_COLUMNS} FROM tasks WHERE status IN ({placeholders}) ORDER BY seq",
|
|
200
|
+
tuple(status.value for status in statuses),
|
|
201
|
+
).fetchall()
|
|
202
|
+
return _rows_to_tasks(connection, rows)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def fetch_task_browser_page(
|
|
206
|
+
connection: sqlite3.Connection,
|
|
207
|
+
*,
|
|
208
|
+
category: str,
|
|
209
|
+
search: str,
|
|
210
|
+
limit: int,
|
|
211
|
+
offset: int,
|
|
212
|
+
diagnosed_stall_task_ids: Sequence[str] = (),
|
|
213
|
+
quota_wait_task_ids: Sequence[str] = (),
|
|
214
|
+
) -> TaskBrowserPage:
|
|
215
|
+
"""Read one ordered browser page and exact counts without loading the full task table."""
|
|
216
|
+
if category not in _BROWSER_FILTERS:
|
|
217
|
+
message = f"unknown task browser filter: {category!r}"
|
|
218
|
+
raise ValueError(message)
|
|
219
|
+
if limit < 1 or offset < 0:
|
|
220
|
+
message = "task browser limit must be positive and offset must be non-negative"
|
|
221
|
+
raise ValueError(message)
|
|
222
|
+
|
|
223
|
+
category_where, category_parameters = _task_browser_category(category, quota_wait_task_ids)
|
|
224
|
+
match_where, match_parameters = _task_browser_search(category_where, search)
|
|
225
|
+
counts = connection.execute(
|
|
226
|
+
"SELECT "
|
|
227
|
+
f"(SELECT COUNT(*) FROM tasks t WHERE {category_where}) AS filtered_count, "
|
|
228
|
+
f"(SELECT COUNT(*) FROM tasks t WHERE {match_where}) AS matching_count, "
|
|
229
|
+
"(SELECT COUNT(*) FROM tasks) AS project_count",
|
|
230
|
+
(*category_parameters, *category_parameters, *match_parameters),
|
|
231
|
+
).fetchone()
|
|
232
|
+
order, order_parameters = _task_browser_order(category, diagnosed_stall_task_ids)
|
|
233
|
+
rows = connection.execute(
|
|
234
|
+
f"SELECT {_TASK_COLUMNS}, "
|
|
235
|
+
"EXISTS (SELECT 1 FROM escalations e WHERE e.task_id = t.id AND e.status = ?) "
|
|
236
|
+
f"AS has_open_decision FROM tasks t WHERE {match_where} "
|
|
237
|
+
f"ORDER BY {order} LIMIT ? OFFSET ?",
|
|
238
|
+
(
|
|
239
|
+
EscalationStatus.OPEN.value,
|
|
240
|
+
*category_parameters,
|
|
241
|
+
*match_parameters,
|
|
242
|
+
*order_parameters,
|
|
243
|
+
limit,
|
|
244
|
+
offset,
|
|
245
|
+
),
|
|
246
|
+
).fetchall()
|
|
247
|
+
tasks = _rows_to_tasks(connection, rows)
|
|
248
|
+
return TaskBrowserPage(
|
|
249
|
+
tasks=tuple(tasks),
|
|
250
|
+
open_decision_task_ids=frozenset(row["id"] for row in rows if row["has_open_decision"]),
|
|
251
|
+
matching_count=counts["matching_count"],
|
|
252
|
+
filtered_count=counts["filtered_count"],
|
|
253
|
+
project_count=counts["project_count"],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _fetch_tasks_by_ids(
|
|
258
|
+
connection: sqlite3.Connection, task_ids: Sequence[str], *, offset: int
|
|
259
|
+
) -> tuple[Task, ...]:
|
|
260
|
+
page_ids = task_ids[offset : offset + _RELATIONSHIP_LIMIT]
|
|
261
|
+
if not page_ids:
|
|
262
|
+
return ()
|
|
263
|
+
placeholders = ", ".join("?" for _ in page_ids)
|
|
264
|
+
rows = connection.execute(
|
|
265
|
+
f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id IN ({placeholders}) ORDER BY seq",
|
|
266
|
+
tuple(page_ids),
|
|
267
|
+
).fetchall()
|
|
268
|
+
return tuple(_rows_to_tasks(connection, rows))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _fetch_relationship_tasks(
|
|
272
|
+
connection: sqlite3.Connection,
|
|
273
|
+
*,
|
|
274
|
+
column: str,
|
|
275
|
+
task_id: str,
|
|
276
|
+
offset: int,
|
|
277
|
+
) -> tuple[tuple[Task, ...], int]:
|
|
278
|
+
rows = connection.execute(
|
|
279
|
+
f"SELECT {_TASK_COLUMNS} FROM tasks WHERE {column} = ? ORDER BY seq LIMIT ? OFFSET ?",
|
|
280
|
+
(task_id, _RELATIONSHIP_LIMIT, offset),
|
|
281
|
+
).fetchall()
|
|
282
|
+
count = connection.execute(
|
|
283
|
+
f"SELECT COUNT(*) FROM tasks WHERE {column} = ?", (task_id,)
|
|
284
|
+
).fetchone()[0]
|
|
285
|
+
return tuple(_rows_to_tasks(connection, rows)), count
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _fetch_dependency_tasks(
|
|
289
|
+
connection: sqlite3.Connection, task_id: str, *, offset: int
|
|
290
|
+
) -> tuple[tuple[Task, ...], int]:
|
|
291
|
+
rows = connection.execute(
|
|
292
|
+
f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id IN "
|
|
293
|
+
"(SELECT depends_on FROM task_deps WHERE task_id = ?) ORDER BY seq LIMIT ? OFFSET ?",
|
|
294
|
+
(task_id, _RELATIONSHIP_LIMIT, offset),
|
|
295
|
+
).fetchall()
|
|
296
|
+
count = connection.execute(
|
|
297
|
+
"SELECT COUNT(*) FROM task_deps WHERE task_id = ?", (task_id,)
|
|
298
|
+
).fetchone()[0]
|
|
299
|
+
return tuple(_rows_to_tasks(connection, rows)), count
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _fetch_blocking_dependency_tasks(
|
|
303
|
+
connection: sqlite3.Connection, task_id: str, *, offset: int
|
|
304
|
+
) -> tuple[tuple[Task, ...], int]:
|
|
305
|
+
rows = connection.execute(
|
|
306
|
+
f"SELECT {_TASK_COLUMNS} FROM tasks WHERE status != ? AND id IN "
|
|
307
|
+
"(SELECT depends_on FROM task_deps WHERE task_id = ?) ORDER BY seq LIMIT ? OFFSET ?",
|
|
308
|
+
(Status.DONE.value, task_id, _RELATIONSHIP_LIMIT, offset),
|
|
309
|
+
).fetchall()
|
|
310
|
+
count = connection.execute(
|
|
311
|
+
"SELECT COUNT(*) FROM tasks WHERE status != ? AND id IN "
|
|
312
|
+
"(SELECT depends_on FROM task_deps WHERE task_id = ?)",
|
|
313
|
+
(Status.DONE.value, task_id),
|
|
314
|
+
).fetchone()[0]
|
|
315
|
+
return tuple(_rows_to_tasks(connection, rows)), count
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _fetch_dependent_tasks(
|
|
319
|
+
connection: sqlite3.Connection, task_id: str, *, offset: int
|
|
320
|
+
) -> tuple[tuple[Task, ...], int]:
|
|
321
|
+
rows = connection.execute(
|
|
322
|
+
f"SELECT {_TASK_COLUMNS} FROM tasks WHERE id IN "
|
|
323
|
+
"(SELECT task_id FROM task_deps WHERE depends_on = ?) ORDER BY seq LIMIT ? OFFSET ?",
|
|
324
|
+
(task_id, _RELATIONSHIP_LIMIT, offset),
|
|
325
|
+
).fetchall()
|
|
326
|
+
count = connection.execute(
|
|
327
|
+
"SELECT COUNT(*) FROM task_deps WHERE depends_on = ?", (task_id,)
|
|
328
|
+
).fetchone()[0]
|
|
329
|
+
return tuple(_rows_to_tasks(connection, rows)), count
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _family_root_id(connection: sqlite3.Connection, task_id: str) -> str:
|
|
333
|
+
row = connection.execute(
|
|
334
|
+
"WITH RECURSIVE ancestors(id, parent_id) AS ("
|
|
335
|
+
"SELECT id, parent_id FROM tasks WHERE id = ? UNION ALL "
|
|
336
|
+
"SELECT parent.id, parent.parent_id FROM tasks parent "
|
|
337
|
+
"JOIN ancestors child ON parent.id = child.parent_id) "
|
|
338
|
+
"SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT ?",
|
|
339
|
+
(task_id, 1),
|
|
340
|
+
).fetchone()
|
|
341
|
+
if row is None:
|
|
342
|
+
message = f"task {task_id} has no family root"
|
|
343
|
+
raise ValueError(message)
|
|
344
|
+
return row[0]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _family_outcomes(connection: sqlite3.Connection, root_id: str) -> tuple[tuple[str, int], ...]:
|
|
348
|
+
rows = connection.execute(
|
|
349
|
+
"WITH RECURSIVE family(id) AS ("
|
|
350
|
+
"SELECT id FROM tasks WHERE id = ? UNION ALL "
|
|
351
|
+
"SELECT child.id FROM tasks child JOIN family parent ON child.parent_id = parent.id) "
|
|
352
|
+
"SELECT tasks.status, tasks.status_reason, COUNT(*) AS count "
|
|
353
|
+
"FROM tasks JOIN family ON family.id = tasks.id GROUP BY tasks.status, tasks.status_reason",
|
|
354
|
+
(root_id,),
|
|
355
|
+
).fetchall()
|
|
356
|
+
counts = dict.fromkeys(_FAMILY_OUTCOME_ORDER, 0)
|
|
357
|
+
for row in rows:
|
|
358
|
+
outcome = (
|
|
359
|
+
StatusReason.SUPERSEDED.value
|
|
360
|
+
if row["status"] == Status.CANCELLED.value
|
|
361
|
+
and row["status_reason"] == StatusReason.SUPERSEDED.value
|
|
362
|
+
else row["status"]
|
|
363
|
+
)
|
|
364
|
+
counts[outcome] += row["count"]
|
|
365
|
+
return tuple((outcome, counts[outcome]) for outcome in _FAMILY_OUTCOME_ORDER)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def fetch_task_relationships(
|
|
369
|
+
connection: sqlite3.Connection,
|
|
370
|
+
task_id: str,
|
|
371
|
+
*,
|
|
372
|
+
offset: int = 0,
|
|
373
|
+
) -> TaskRelationships | None:
|
|
374
|
+
"""Read one bounded task-relationship page from a consistent database snapshot."""
|
|
375
|
+
if offset < 0:
|
|
376
|
+
message = "task relationship offset cannot be negative"
|
|
377
|
+
raise ValueError(message)
|
|
378
|
+
with transaction(connection):
|
|
379
|
+
task = fetch_task(connection, task_id)
|
|
380
|
+
if task is None:
|
|
381
|
+
return None
|
|
382
|
+
replacement_task_ids: list[str] = []
|
|
383
|
+
if task.status_reason is StatusReason.SUPERSEDED:
|
|
384
|
+
events = fetch_latest_events(
|
|
385
|
+
connection,
|
|
386
|
+
1,
|
|
387
|
+
kinds=[EventKind.SCOPE_CHANGE],
|
|
388
|
+
task_id=task.id,
|
|
389
|
+
)
|
|
390
|
+
if events:
|
|
391
|
+
recorded_ids = events[0].data.get("new_task_ids")
|
|
392
|
+
if isinstance(recorded_ids, list):
|
|
393
|
+
replacement_task_ids = [
|
|
394
|
+
replacement_id
|
|
395
|
+
for replacement_id in cast("list[object]", recorded_ids)
|
|
396
|
+
if isinstance(replacement_id, str)
|
|
397
|
+
]
|
|
398
|
+
family_parent = (
|
|
399
|
+
fetch_task(connection, task.parent_id) if task.parent_id is not None else task
|
|
400
|
+
)
|
|
401
|
+
if family_parent is None:
|
|
402
|
+
message = f"task {task.id} has a missing parent {task.parent_id}"
|
|
403
|
+
raise ValueError(message)
|
|
404
|
+
family_children, family_child_count = _fetch_relationship_tasks(
|
|
405
|
+
connection,
|
|
406
|
+
column="parent_id",
|
|
407
|
+
task_id=task.id,
|
|
408
|
+
offset=offset,
|
|
409
|
+
)
|
|
410
|
+
dependencies, dependency_count = _fetch_dependency_tasks(connection, task.id, offset=offset)
|
|
411
|
+
blocking_dependencies, blocking_dependency_count = _fetch_blocking_dependency_tasks(
|
|
412
|
+
connection,
|
|
413
|
+
task.id,
|
|
414
|
+
offset=offset,
|
|
415
|
+
)
|
|
416
|
+
dependents, dependent_count = _fetch_dependent_tasks(connection, task.id, offset=offset)
|
|
417
|
+
replacements = tuple(
|
|
418
|
+
replacement
|
|
419
|
+
for replacement in _fetch_tasks_by_ids(
|
|
420
|
+
connection,
|
|
421
|
+
replacement_task_ids,
|
|
422
|
+
offset=offset,
|
|
423
|
+
)
|
|
424
|
+
if replacement.parent_id == task.id
|
|
425
|
+
)
|
|
426
|
+
root_id = _family_root_id(connection, task.id)
|
|
427
|
+
family_root = fetch_task(connection, root_id)
|
|
428
|
+
if family_root is None:
|
|
429
|
+
message = f"family root {root_id} disappeared while reading {task.id}"
|
|
430
|
+
raise ValueError(message)
|
|
431
|
+
return TaskRelationships(
|
|
432
|
+
family_parent=family_parent,
|
|
433
|
+
family_root=family_root,
|
|
434
|
+
family_children=family_children,
|
|
435
|
+
family_child_count=family_child_count,
|
|
436
|
+
dependencies=dependencies,
|
|
437
|
+
dependency_count=dependency_count,
|
|
438
|
+
blocking_dependencies=blocking_dependencies,
|
|
439
|
+
blocking_dependency_count=blocking_dependency_count,
|
|
440
|
+
dependents=dependents,
|
|
441
|
+
dependent_count=dependent_count,
|
|
442
|
+
replacements=replacements,
|
|
443
|
+
replacement_count=len(replacement_task_ids),
|
|
444
|
+
family_outcomes=_family_outcomes(connection, root_id),
|
|
445
|
+
page_offset=offset,
|
|
446
|
+
page_size=_RELATIONSHIP_LIMIT,
|
|
447
|
+
has_more=any(
|
|
448
|
+
offset + _RELATIONSHIP_LIMIT < count
|
|
449
|
+
for count in (
|
|
450
|
+
family_child_count,
|
|
451
|
+
dependency_count,
|
|
452
|
+
blocking_dependency_count,
|
|
453
|
+
dependent_count,
|
|
454
|
+
len(replacement_task_ids),
|
|
455
|
+
)
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def fetch_task_browser_pages(
|
|
461
|
+
connection: sqlite3.Connection,
|
|
462
|
+
*,
|
|
463
|
+
category: str,
|
|
464
|
+
search: str,
|
|
465
|
+
limit: int,
|
|
466
|
+
page_count: int,
|
|
467
|
+
diagnosed_stall_task_ids: Sequence[str] = (),
|
|
468
|
+
quota_wait_task_ids: Sequence[str] = (),
|
|
469
|
+
) -> tuple[TaskBrowserPage, ...]:
|
|
470
|
+
"""Read a bounded page range from one SQLite snapshot."""
|
|
471
|
+
if page_count < 1:
|
|
472
|
+
message = "task browser page count must be positive"
|
|
473
|
+
raise ValueError(message)
|
|
474
|
+
|
|
475
|
+
with transaction(connection):
|
|
476
|
+
pages: list[TaskBrowserPage] = []
|
|
477
|
+
for page_index in range(page_count):
|
|
478
|
+
offset = page_index * limit
|
|
479
|
+
page = fetch_task_browser_page(
|
|
480
|
+
connection,
|
|
481
|
+
category=category,
|
|
482
|
+
search=search,
|
|
483
|
+
limit=limit,
|
|
484
|
+
offset=offset,
|
|
485
|
+
diagnosed_stall_task_ids=diagnosed_stall_task_ids,
|
|
486
|
+
quota_wait_task_ids=quota_wait_task_ids,
|
|
487
|
+
)
|
|
488
|
+
pages.append(page)
|
|
489
|
+
if offset + len(page.tasks) >= page.matching_count:
|
|
490
|
+
break
|
|
491
|
+
return tuple(pages)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _task_browser_category(
|
|
495
|
+
category: str,
|
|
496
|
+
quota_wait_task_ids: Sequence[str],
|
|
497
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
498
|
+
if category == _BROWSER_CATEGORY_ALL:
|
|
499
|
+
return "1 = 1", ()
|
|
500
|
+
if category in _BROWSER_STATUS_FILTERS:
|
|
501
|
+
return "t.status = ?", (category,)
|
|
502
|
+
if category == _BROWSER_CATEGORY_WAITING:
|
|
503
|
+
quota_wait = ""
|
|
504
|
+
parameters: tuple[str, ...] = ()
|
|
505
|
+
if quota_wait_task_ids:
|
|
506
|
+
placeholders = ", ".join("?" for _ in quota_wait_task_ids)
|
|
507
|
+
quota_wait = f" OR t.id IN ({placeholders})"
|
|
508
|
+
parameters = tuple(quota_wait_task_ids)
|
|
509
|
+
return (
|
|
510
|
+
(
|
|
511
|
+
"(t.status = ? OR t.status = ? OR "
|
|
512
|
+
"(t.status IN (?, ?) AND "
|
|
513
|
+
f"(json_array_length(t.holds) > 0 OR t.wait_reason IS NOT NULL{quota_wait})))"
|
|
514
|
+
),
|
|
515
|
+
(
|
|
516
|
+
Status.QUEUED.value,
|
|
517
|
+
Status.ESCALATED.value,
|
|
518
|
+
Status.RUNNING.value,
|
|
519
|
+
Status.INTEGRATING.value,
|
|
520
|
+
*parameters,
|
|
521
|
+
),
|
|
522
|
+
)
|
|
523
|
+
statuses = {
|
|
524
|
+
_BROWSER_CATEGORY_ACTIVE: (Status.RUNNING, Status.INTEGRATING, Status.ESCALATED),
|
|
525
|
+
_BROWSER_CATEGORY_HISTORY: (Status.DONE, Status.CANCELLED),
|
|
526
|
+
}
|
|
527
|
+
selected = statuses[category]
|
|
528
|
+
placeholders = ", ".join("?" for _ in selected)
|
|
529
|
+
return (
|
|
530
|
+
f"t.status IN ({placeholders})",
|
|
531
|
+
tuple(status.value for status in selected),
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _task_browser_search(category_where: str, search: str) -> tuple[str, tuple[str, ...]]:
|
|
536
|
+
if not search:
|
|
537
|
+
return category_where, ()
|
|
538
|
+
return (
|
|
539
|
+
(
|
|
540
|
+
f"{category_where} AND "
|
|
541
|
+
"(instr(casefold(t.id), casefold(?)) > 0 OR "
|
|
542
|
+
"instr(casefold(t.title), casefold(?)) > 0)"
|
|
543
|
+
),
|
|
544
|
+
(search, search),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _task_browser_order(
|
|
549
|
+
category: str,
|
|
550
|
+
diagnosed_stall_task_ids: Sequence[str],
|
|
551
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
552
|
+
queued = Status.QUEUED.value
|
|
553
|
+
running = Status.RUNNING.value
|
|
554
|
+
integrating = Status.INTEGRATING.value
|
|
555
|
+
escalated = Status.ESCALATED.value
|
|
556
|
+
done = Status.DONE.value
|
|
557
|
+
cancelled = Status.CANCELLED.value
|
|
558
|
+
open_status = EscalationStatus.OPEN.value
|
|
559
|
+
decision_first = (
|
|
560
|
+
f"CASE WHEN t.status = '{escalated}' OR EXISTS ("
|
|
561
|
+
f"SELECT 1 FROM escalations e WHERE e.task_id = t.id AND e.status = '{open_status}'"
|
|
562
|
+
") THEN 0 ELSE 1 END"
|
|
563
|
+
)
|
|
564
|
+
if category == _BROWSER_CATEGORY_ACTIVE:
|
|
565
|
+
stall_first = ""
|
|
566
|
+
if diagnosed_stall_task_ids:
|
|
567
|
+
placeholders = ", ".join("?" for _ in diagnosed_stall_task_ids)
|
|
568
|
+
stall_first = f"CASE WHEN t.id IN ({placeholders}) THEN 0 ELSE 1 END, "
|
|
569
|
+
return (
|
|
570
|
+
f"{decision_first}, {stall_first}COALESCE(t.started_at, t.created_at), t.seq",
|
|
571
|
+
tuple(diagnosed_stall_task_ids),
|
|
572
|
+
)
|
|
573
|
+
if category == _BROWSER_CATEGORY_WAITING:
|
|
574
|
+
return (
|
|
575
|
+
(
|
|
576
|
+
f"CASE WHEN t.status = '{queued}' THEN 0 ELSE 1 END, "
|
|
577
|
+
f"CASE WHEN t.status = '{queued}' AND t.requeued_at IS NULL THEN 1 ELSE 0 END, "
|
|
578
|
+
f"CASE WHEN t.status = '{queued}' THEN -t.priority ELSE 0 END, "
|
|
579
|
+
f"CASE WHEN t.status = '{queued}' THEN t.seq END, "
|
|
580
|
+
f"CASE WHEN t.status != '{queued}' THEN COALESCE(t.started_at, t.created_at) END, "
|
|
581
|
+
"t.seq"
|
|
582
|
+
),
|
|
583
|
+
(),
|
|
584
|
+
)
|
|
585
|
+
if category == _BROWSER_CATEGORY_HISTORY:
|
|
586
|
+
return "t.finished_at DESC, t.seq DESC", ()
|
|
587
|
+
return (
|
|
588
|
+
(
|
|
589
|
+
f"CASE WHEN t.status IN ('{done}', '{cancelled}') THEN 1 ELSE 0 END, "
|
|
590
|
+
f"{decision_first}, "
|
|
591
|
+
f"CASE WHEN t.status = '{queued}' THEN 1 ELSE 0 END, "
|
|
592
|
+
f"CASE WHEN t.status = '{queued}' AND t.requeued_at IS NULL THEN 1 ELSE 0 END, "
|
|
593
|
+
f"CASE WHEN t.status = '{queued}' THEN -t.priority ELSE 0 END, "
|
|
594
|
+
f"CASE WHEN t.status = '{queued}' THEN t.seq END, "
|
|
595
|
+
f"CASE WHEN t.status IN ('{running}', '{integrating}', '{escalated}') "
|
|
596
|
+
"THEN COALESCE(t.started_at, t.created_at) END, "
|
|
597
|
+
f"CASE WHEN t.status IN ('{done}', '{cancelled}') THEN t.finished_at END DESC, "
|
|
598
|
+
"t.seq"
|
|
599
|
+
),
|
|
600
|
+
(),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class TaskStore:
|
|
605
|
+
"""Inserts task rows, applies transitions and updates their scheduling fields."""
|
|
606
|
+
|
|
607
|
+
def __init__(self, connection: sqlite3.Connection, clock: Clock) -> None:
|
|
608
|
+
"""Use `connection` (the engine's writer) and `clock` for every write below."""
|
|
609
|
+
self._connection = connection
|
|
610
|
+
self._clock = clock
|
|
611
|
+
|
|
612
|
+
def insert_from_submit(self, submit: Submit) -> Task:
|
|
613
|
+
"""Insert a task using the ID allocated by a `Submit` command."""
|
|
614
|
+
task_id = submit.task_id
|
|
615
|
+
if task_id is None:
|
|
616
|
+
message = "submit command has no allocated task id"
|
|
617
|
+
raise ValueError(message)
|
|
618
|
+
seq = int(task_id.removeprefix(TASK_ID_PREFIX))
|
|
619
|
+
overrides: dict[Role, Rung] = {}
|
|
620
|
+
if submit.leader is not None:
|
|
621
|
+
overrides[Role.LEADER] = submit.leader
|
|
622
|
+
if submit.implementer is not None:
|
|
623
|
+
overrides[Role.IMPLEMENTER] = submit.implementer
|
|
624
|
+
with transaction(self._connection):
|
|
625
|
+
self._insert_row(
|
|
626
|
+
task_id=task_id,
|
|
627
|
+
seq=seq,
|
|
628
|
+
parent_id=None,
|
|
629
|
+
origin=TaskOrigin.OPERATOR,
|
|
630
|
+
title=submit.title,
|
|
631
|
+
request=submit.request,
|
|
632
|
+
mode=submit.mode,
|
|
633
|
+
complexity=submit.complexity,
|
|
634
|
+
priority=submit.priority,
|
|
635
|
+
depends_on=submit.depends_on,
|
|
636
|
+
overrides=overrides,
|
|
637
|
+
)
|
|
638
|
+
return self._fetch_or_raise(task_id)
|
|
639
|
+
|
|
640
|
+
def apply_scope_change(
|
|
641
|
+
self,
|
|
642
|
+
task_id: str,
|
|
643
|
+
transition: Transition,
|
|
644
|
+
new_tasks: Sequence[NewTask],
|
|
645
|
+
*,
|
|
646
|
+
origin: TaskOrigin,
|
|
647
|
+
) -> list[Task]:
|
|
648
|
+
"""Atomically transition a parent, enqueue children and repoint its dependents."""
|
|
649
|
+
with transaction(self._connection):
|
|
650
|
+
parent = fetch_task(self._connection, task_id)
|
|
651
|
+
if parent is None:
|
|
652
|
+
message = f"task {task_id} does not exist"
|
|
653
|
+
raise UsageError(message)
|
|
654
|
+
self._validate_scope_references(new_tasks)
|
|
655
|
+
self.apply_transition(task_id, transition)
|
|
656
|
+
if transition.status in (Status.DONE, Status.CANCELLED):
|
|
657
|
+
self.set_slot(task_id, None)
|
|
658
|
+
child_ids = [self._next_task_id() for _ in new_tasks]
|
|
659
|
+
resolved_dependencies = [
|
|
660
|
+
[resolve_dependency(ref, child_ids) for ref in new_task.depends_on]
|
|
661
|
+
for new_task in new_tasks
|
|
662
|
+
]
|
|
663
|
+
child_dependencies: list[tuple[str, str]] = []
|
|
664
|
+
await_parent = not transition.repoint_dependents
|
|
665
|
+
for child_id, new_task, depends_on in zip(
|
|
666
|
+
child_ids, new_tasks, resolved_dependencies, strict=True
|
|
667
|
+
):
|
|
668
|
+
self._insert_row(
|
|
669
|
+
task_id=child_id,
|
|
670
|
+
seq=int(child_id.removeprefix(TASK_ID_PREFIX)),
|
|
671
|
+
parent_id=parent.id,
|
|
672
|
+
origin=origin,
|
|
673
|
+
title=new_task.title,
|
|
674
|
+
request=new_task.request,
|
|
675
|
+
mode=None,
|
|
676
|
+
complexity=None,
|
|
677
|
+
priority=new_task.priority,
|
|
678
|
+
depends_on=(),
|
|
679
|
+
overrides={},
|
|
680
|
+
)
|
|
681
|
+
child_dependencies.extend((child_id, dependency) for dependency in depends_on)
|
|
682
|
+
if await_parent and parent.id not in depends_on:
|
|
683
|
+
child_dependencies.append((child_id, parent.id))
|
|
684
|
+
self._connection.executemany(
|
|
685
|
+
"INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)", child_dependencies
|
|
686
|
+
)
|
|
687
|
+
if transition.repoint_dependents:
|
|
688
|
+
self._repoint_dependents(task_id, child_ids)
|
|
689
|
+
self._validate_acyclic()
|
|
690
|
+
return [self._fetch_or_raise(child_id) for child_id in child_ids]
|
|
691
|
+
|
|
692
|
+
def _insert_row(
|
|
693
|
+
self,
|
|
694
|
+
*,
|
|
695
|
+
task_id: str,
|
|
696
|
+
seq: int,
|
|
697
|
+
parent_id: str | None,
|
|
698
|
+
origin: TaskOrigin,
|
|
699
|
+
title: str,
|
|
700
|
+
request: str,
|
|
701
|
+
mode: Mode | None,
|
|
702
|
+
complexity: ComplexityName | None,
|
|
703
|
+
priority: int,
|
|
704
|
+
depends_on: Sequence[str],
|
|
705
|
+
overrides: dict[Role, Rung],
|
|
706
|
+
) -> None:
|
|
707
|
+
self._connection.execute(
|
|
708
|
+
"""
|
|
709
|
+
INSERT INTO tasks (
|
|
710
|
+
id, seq, parent_id, origin, title, request, mode, complexity,
|
|
711
|
+
complexity_explicit, priority, status, overrides, created_at
|
|
712
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
713
|
+
""",
|
|
714
|
+
(
|
|
715
|
+
task_id,
|
|
716
|
+
seq,
|
|
717
|
+
parent_id,
|
|
718
|
+
origin.value,
|
|
719
|
+
title,
|
|
720
|
+
request,
|
|
721
|
+
mode.value if mode is not None else None,
|
|
722
|
+
complexity or DEFAULT_COMPLEXITY,
|
|
723
|
+
complexity is not None,
|
|
724
|
+
priority,
|
|
725
|
+
Status.QUEUED.value,
|
|
726
|
+
dump_json(_OVERRIDES_ADAPTER, overrides),
|
|
727
|
+
format_timestamp(self._clock.now()),
|
|
728
|
+
),
|
|
729
|
+
)
|
|
730
|
+
self._connection.executemany(
|
|
731
|
+
"INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)",
|
|
732
|
+
[(task_id, dep) for dep in depends_on],
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def _next_task_id(self) -> str:
|
|
736
|
+
return f"{TASK_ID_PREFIX}{next_sequence(self._connection, TASK_SEQUENCE)}"
|
|
737
|
+
|
|
738
|
+
def _validate_scope_references(self, new_tasks: Sequence[NewTask]) -> None:
|
|
739
|
+
existing_references = {
|
|
740
|
+
ref
|
|
741
|
+
for new_task in new_tasks
|
|
742
|
+
for ref in new_task.depends_on
|
|
743
|
+
if not ref.startswith(SIBLING_REFERENCE_PREFIX)
|
|
744
|
+
}
|
|
745
|
+
if existing_references:
|
|
746
|
+
placeholders = ", ".join("?" for _ in existing_references)
|
|
747
|
+
rows = self._connection.execute(
|
|
748
|
+
f"SELECT id FROM tasks WHERE id IN ({placeholders})",
|
|
749
|
+
tuple(existing_references),
|
|
750
|
+
).fetchall()
|
|
751
|
+
found = {row["id"] for row in rows}
|
|
752
|
+
missing = existing_references - found
|
|
753
|
+
if missing:
|
|
754
|
+
reference = min(missing)
|
|
755
|
+
message = f"scope change dependency {reference!r} does not exist"
|
|
756
|
+
raise UsageError(message)
|
|
757
|
+
|
|
758
|
+
def _validate_acyclic(self) -> None:
|
|
759
|
+
graph: dict[str, list[str]] = {}
|
|
760
|
+
for row in self._connection.execute("SELECT task_id, depends_on FROM task_deps"):
|
|
761
|
+
graph.setdefault(row["task_id"], []).append(row["depends_on"])
|
|
762
|
+
cycle = find_dependency_cycle(graph)
|
|
763
|
+
if cycle:
|
|
764
|
+
message = f"scope change creates a dependency cycle: {', '.join(cycle)}"
|
|
765
|
+
raise UsageError(message)
|
|
766
|
+
|
|
767
|
+
def _fetch_or_raise(self, task_id: str) -> Task:
|
|
768
|
+
task = fetch_task(self._connection, task_id)
|
|
769
|
+
return require_after_insert(task, f"task {task_id} vanished immediately after insert")
|
|
770
|
+
|
|
771
|
+
def get(self, task_id: str) -> Task | None:
|
|
772
|
+
"""Read one task by id, or `None` if it doesn't exist."""
|
|
773
|
+
return fetch_task(self._connection, task_id)
|
|
774
|
+
|
|
775
|
+
def require(self, task_id: str) -> Task:
|
|
776
|
+
"""Read one task by id, or raise when it disappeared."""
|
|
777
|
+
task = self.get(task_id)
|
|
778
|
+
if task is None:
|
|
779
|
+
message = f"{task_id} vanished while its flow was running"
|
|
780
|
+
raise InvariantError(message)
|
|
781
|
+
return task
|
|
782
|
+
|
|
783
|
+
def list_active(self) -> list[Task]:
|
|
784
|
+
"""Read every task holding a slot: `running`, `integrating` or `escalated`."""
|
|
785
|
+
return fetch_tasks(self._connection, statuses=ACTIVE_STATUSES)
|
|
786
|
+
|
|
787
|
+
def list_all(self) -> list[Task]:
|
|
788
|
+
"""Read every task, the universe `domain.scheduling.pick_ready` needs for dep lookups."""
|
|
789
|
+
return fetch_tasks(self._connection)
|
|
790
|
+
|
|
791
|
+
def apply_transition(self, task_id: str, transition: Transition) -> None:
|
|
792
|
+
"""Persist `transition`'s step, status, iteration, counters, rungs and triage results.
|
|
793
|
+
|
|
794
|
+
The decision is reconciled with the stored row in the same write
|
|
795
|
+
(`Transition.rebased_onto`): only what it changed relative to its `base` is applied, and a
|
|
796
|
+
rung the operator set since wins over the turn's rung move and rejection count.
|
|
797
|
+
Overrides merge the same way: the transition sets its own roles and clears a role only
|
|
798
|
+
while it still holds the rung the transition saw. Complexity and title are written only
|
|
799
|
+
when the transition sets them.
|
|
800
|
+
"""
|
|
801
|
+
current = self.require(task_id)
|
|
802
|
+
overrides = self._merged_overrides(current, transition)
|
|
803
|
+
transition = transition.rebased_onto(current)
|
|
804
|
+
now = format_timestamp(self._clock.now())
|
|
805
|
+
status = transition.status.value
|
|
806
|
+
self._connection.execute(
|
|
807
|
+
"""
|
|
808
|
+
UPDATE tasks SET
|
|
809
|
+
step = ?, status = ?, status_reason = ?, iteration = ?,
|
|
810
|
+
counters = ?, rung_index = ?, overrides = ?,
|
|
811
|
+
complexity = COALESCE(?, complexity),
|
|
812
|
+
complexity_explicit = complexity_explicit OR ? IS NOT NULL,
|
|
813
|
+
mode = COALESCE(?, mode), title = COALESCE(?, title),
|
|
814
|
+
started_at = CASE WHEN ? = ? AND started_at IS NULL THEN ? ELSE started_at END,
|
|
815
|
+
finished_at = CASE WHEN ? IN (?, ?) AND finished_at IS NULL
|
|
816
|
+
THEN ? ELSE finished_at END,
|
|
817
|
+
wait_reason = CASE WHEN ? IN (?, ?) THEN NULL ELSE wait_reason END
|
|
818
|
+
WHERE id = ?
|
|
819
|
+
""",
|
|
820
|
+
(
|
|
821
|
+
transition.step.value if transition.step is not None else None,
|
|
822
|
+
status,
|
|
823
|
+
transition.status_reason,
|
|
824
|
+
transition.iteration,
|
|
825
|
+
dump_json(_COUNTERS_ADAPTER, transition.counters),
|
|
826
|
+
dump_json(_RUNG_INDEX_ADAPTER, transition.rung_index),
|
|
827
|
+
dump_json(_OVERRIDES_ADAPTER, overrides),
|
|
828
|
+
transition.complexity,
|
|
829
|
+
transition.complexity,
|
|
830
|
+
transition.mode.value if transition.mode is not None else None,
|
|
831
|
+
transition.title,
|
|
832
|
+
status,
|
|
833
|
+
Status.RUNNING.value,
|
|
834
|
+
now,
|
|
835
|
+
status,
|
|
836
|
+
Status.DONE.value,
|
|
837
|
+
Status.CANCELLED.value,
|
|
838
|
+
now,
|
|
839
|
+
status,
|
|
840
|
+
Status.DONE.value,
|
|
841
|
+
Status.CANCELLED.value,
|
|
842
|
+
task_id,
|
|
843
|
+
),
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
@staticmethod
|
|
847
|
+
def _merged_overrides(current: Task, transition: Transition) -> dict[Role, Rung]:
|
|
848
|
+
kept = {
|
|
849
|
+
role: rung
|
|
850
|
+
for role, rung in current.overrides.items()
|
|
851
|
+
if transition.cleared_overrides.get(role) != rung
|
|
852
|
+
}
|
|
853
|
+
return {**kept, **transition.set_overrides}
|
|
854
|
+
|
|
855
|
+
def set_holds(self, task_id: str, holds: frozenset[Hold]) -> None:
|
|
856
|
+
"""Persist `task_id`'s task-scoped holds."""
|
|
857
|
+
self._connection.execute(
|
|
858
|
+
"UPDATE tasks SET holds = ? WHERE id = ?",
|
|
859
|
+
(dump_json(_HOLDS_ADAPTER, holds), task_id),
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
def set_priority(self, task_id: str, priority: int) -> None:
|
|
863
|
+
"""Set `task_id`'s dispatch priority (`prioritize`)."""
|
|
864
|
+
self._connection.execute("UPDATE tasks SET priority = ? WHERE id = ?", (priority, task_id))
|
|
865
|
+
|
|
866
|
+
def set_wait_reason(self, task_id: str, reason: WaitReason | None) -> None:
|
|
867
|
+
"""Persist why the scheduler isn't dispatching `task_id` yet."""
|
|
868
|
+
self._connection.execute(
|
|
869
|
+
"UPDATE tasks SET wait_reason = ? WHERE id = ?",
|
|
870
|
+
(reason.value if reason is not None else None, task_id),
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
def finish(
|
|
874
|
+
self,
|
|
875
|
+
task_id: str,
|
|
876
|
+
transition: Transition,
|
|
877
|
+
event: EventKind,
|
|
878
|
+
*,
|
|
879
|
+
message: str,
|
|
880
|
+
data: dict[str, Any],
|
|
881
|
+
) -> None:
|
|
882
|
+
"""Enter a terminal `transition`, free the slot and append its terminal `event` at once.
|
|
883
|
+
|
|
884
|
+
Status and event land in one transaction, so a restart never sees a terminal task
|
|
885
|
+
without its terminal event, nor the event twice.
|
|
886
|
+
"""
|
|
887
|
+
with transaction(self._connection):
|
|
888
|
+
self.apply_transition(task_id, transition)
|
|
889
|
+
self.set_slot(task_id, None)
|
|
890
|
+
EventLog(self._connection, self._clock).append(
|
|
891
|
+
event, task_id=task_id, message=message, data=data
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
def set_slot(self, task_id: str, slot: int | None) -> None:
|
|
895
|
+
"""Assign or clear `task_id`'s dispatch slot."""
|
|
896
|
+
self._connection.execute("UPDATE tasks SET slot = ? WHERE id = ?", (slot, task_id))
|
|
897
|
+
|
|
898
|
+
def set_window(self, task_id: str, window_id: str | None) -> None:
|
|
899
|
+
"""Record `task_id`'s tmux window id."""
|
|
900
|
+
self._connection.execute(
|
|
901
|
+
"UPDATE tasks SET window_id = ? WHERE id = ?", (window_id, task_id)
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
def set_panes(self, task_id: str, panes: dict[Role, str]) -> None:
|
|
905
|
+
"""Record `task_id`'s `{role: pane_id}` map, the only place pane ids live."""
|
|
906
|
+
self._connection.execute(
|
|
907
|
+
"UPDATE tasks SET panes = ? WHERE id = ?",
|
|
908
|
+
(dump_json(_PANES_ADAPTER, panes), task_id),
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
def set_repo(self, task_id: str, repo: RepoState) -> None:
|
|
912
|
+
"""Record `task_id`'s checkout state."""
|
|
913
|
+
self._connection.execute(
|
|
914
|
+
"UPDATE tasks SET repo = ? WHERE id = ?",
|
|
915
|
+
(dump_json(_REPO_ADAPTER, repo), task_id),
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
def set_requeued(self, task_id: str) -> None:
|
|
919
|
+
"""Mark `task_id` requeued now, giving it dispatch precedence."""
|
|
920
|
+
self._connection.execute(
|
|
921
|
+
"UPDATE tasks SET requeued_at = ? WHERE id = ?",
|
|
922
|
+
(format_timestamp(self._clock.now()), task_id),
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
def set_summary(self, task_id: str, summary: str) -> None:
|
|
926
|
+
"""Record `task_id`'s final summary."""
|
|
927
|
+
self._connection.execute("UPDATE tasks SET summary = ? WHERE id = ?", (summary, task_id))
|
|
928
|
+
|
|
929
|
+
def set_depends_on(self, task_id: str, depends_on: Sequence[str]) -> None:
|
|
930
|
+
"""Replace `task_id`'s dependency set, including sibling references from a plan."""
|
|
931
|
+
with transaction(self._connection):
|
|
932
|
+
self._connection.execute("DELETE FROM task_deps WHERE task_id = ?", (task_id,))
|
|
933
|
+
self._connection.executemany(
|
|
934
|
+
"INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)",
|
|
935
|
+
[(task_id, dep) for dep in depends_on],
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
def _repoint_dependents(self, old_task_id: str, new_task_ids: Sequence[str]) -> None:
|
|
939
|
+
dependents = [
|
|
940
|
+
row["task_id"]
|
|
941
|
+
for row in self._connection.execute(
|
|
942
|
+
"SELECT task_id FROM task_deps WHERE depends_on = ?", (old_task_id,)
|
|
943
|
+
).fetchall()
|
|
944
|
+
]
|
|
945
|
+
self._connection.execute("DELETE FROM task_deps WHERE depends_on = ?", (old_task_id,))
|
|
946
|
+
self._connection.executemany(
|
|
947
|
+
"INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)",
|
|
948
|
+
[(dependent, new_task_id) for dependent in dependents for new_task_id in new_task_ids],
|
|
949
|
+
)
|