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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Pure derivation of a task's Results from recorded data: no git call, nothing guessed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import TYPE_CHECKING, cast
|
|
8
|
+
|
|
9
|
+
from shikiru.config.settings import SQUASH_INTEGRATION
|
|
10
|
+
from shikiru.domain.briefing import ProducedNumbers, facts_commit_lines, parse_produced
|
|
11
|
+
from shikiru.domain.tasks import Status
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from shikiru.domain.briefing import PullRequestLine, ReportedTests
|
|
17
|
+
from shikiru.domain.reports import Commit
|
|
18
|
+
|
|
19
|
+
SHORT_SHA_LENGTH = 7
|
|
20
|
+
DONE_COMMITS_KEY = "commits"
|
|
21
|
+
DONE_RISKS_KEY = "residual_risks"
|
|
22
|
+
DONE_INTEGRATION_KEY = "integration"
|
|
23
|
+
DONE_CHANGES_KEY = "changes"
|
|
24
|
+
CHANGES_FILES_KEY = "files"
|
|
25
|
+
CHANGES_INSERTIONS_KEY = "insertions"
|
|
26
|
+
CHANGES_DELETIONS_KEY = "deletions"
|
|
27
|
+
SCOPE_CHANGES_KEY = "scope_changes"
|
|
28
|
+
NEW_TASK_IDS_KEY = "new_task_ids"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResultOutcome(StrEnum):
|
|
32
|
+
"""Whether the task's work reached its base branch."""
|
|
33
|
+
|
|
34
|
+
MERGED = "merged"
|
|
35
|
+
NOT_MERGED = "not merged"
|
|
36
|
+
SO_FAR = "so far"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ResultCommit:
|
|
41
|
+
"""A commit as a short hash and its subject."""
|
|
42
|
+
|
|
43
|
+
sha: str
|
|
44
|
+
subject: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class LiveWork:
|
|
49
|
+
"""A task's work as its worktree holds it now: commits past its base and the changes."""
|
|
50
|
+
|
|
51
|
+
commits: tuple[ResultCommit, ...]
|
|
52
|
+
produced: ProducedNumbers
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_oneline(
|
|
56
|
+
cls, commit_lines: Sequence[str], *, files: int, insertions: int, deletions: int
|
|
57
|
+
) -> LiveWork:
|
|
58
|
+
"""Build the observation from `git log --oneline` lines and the change totals."""
|
|
59
|
+
commits = tuple(_from_oneline(line) for line in commit_lines)
|
|
60
|
+
return cls(commits, ProducedNumbers(len(commits), files, insertions, deletions))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PlanChange:
|
|
65
|
+
"""One recorded scope change: what changed and the tasks it created as (id, title or None)."""
|
|
66
|
+
|
|
67
|
+
scope_changes: tuple[str, ...]
|
|
68
|
+
new_tasks: tuple[tuple[str, str | None], ...]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class TaskResults:
|
|
73
|
+
"""What a task produced; `more_commits` counts the commits beyond the capped list.
|
|
74
|
+
|
|
75
|
+
`live_error` is set when the task's current Git state could not be read, so the commits and
|
|
76
|
+
numbers shown are the recorded ones and must be labelled as such.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
outcome: ResultOutcome
|
|
80
|
+
branch: str | None
|
|
81
|
+
base: str | None
|
|
82
|
+
commits: tuple[ResultCommit, ...]
|
|
83
|
+
more_commits: int
|
|
84
|
+
produced: ProducedNumbers | None
|
|
85
|
+
tests: ReportedTests | None
|
|
86
|
+
pull_request: PullRequestLine | None
|
|
87
|
+
plan_changes: tuple[PlanChange, ...]
|
|
88
|
+
plan_summary: str | None
|
|
89
|
+
residual_risks: tuple[str, ...]
|
|
90
|
+
live_error: str | None
|
|
91
|
+
squashed: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _strings(value: object) -> tuple[str, ...]:
|
|
95
|
+
items = cast("list[object]", value) if isinstance(value, list) else []
|
|
96
|
+
return tuple(item for item in items if isinstance(item, str))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _from_oneline(line: str) -> ResultCommit:
|
|
100
|
+
sha, _, subject = line.partition(" ")
|
|
101
|
+
return ResultCommit(sha[:SHORT_SHA_LENGTH], subject.strip())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _outcome(status: Status) -> ResultOutcome:
|
|
105
|
+
if status is Status.DONE:
|
|
106
|
+
return ResultOutcome.MERGED
|
|
107
|
+
return ResultOutcome.NOT_MERGED if status is Status.CANCELLED else ResultOutcome.SO_FAR
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tracks_live_work(status: Status) -> bool:
|
|
111
|
+
"""Whether a task still shows live work; a finished one keeps its recorded Results."""
|
|
112
|
+
return status not in (Status.DONE, Status.CANCELLED)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def new_task_ids(scope_events: Sequence[Mapping[str, object]]) -> tuple[str, ...]:
|
|
116
|
+
"""Return the distinct ids of the tasks the recorded scope changes created, sorted."""
|
|
117
|
+
return tuple(
|
|
118
|
+
sorted(
|
|
119
|
+
{task_id for data in scope_events for task_id in _strings(data.get(NEW_TASK_IDS_KEY))}
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def landed_changes(done: Mapping[str, object]) -> tuple[int, int, int] | None:
|
|
125
|
+
"""Return the recorded (files, insertions, deletions) of `done`, or `None` when absent."""
|
|
126
|
+
changes = done.get(DONE_CHANGES_KEY)
|
|
127
|
+
if not isinstance(changes, dict):
|
|
128
|
+
return None
|
|
129
|
+
found = cast("dict[str, object]", changes)
|
|
130
|
+
counts = tuple(
|
|
131
|
+
found.get(key) for key in (CHANGES_FILES_KEY, CHANGES_INSERTIONS_KEY, CHANGES_DELETIONS_KEY)
|
|
132
|
+
)
|
|
133
|
+
if not all(isinstance(count, int) for count in counts):
|
|
134
|
+
return None
|
|
135
|
+
files, insertions, deletions = cast("tuple[int, int, int]", counts)
|
|
136
|
+
return files, insertions, deletions
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _residual_risks(done: Mapping[str, object], verdict_risks: Sequence[str]) -> tuple[str, ...]:
|
|
140
|
+
if DONE_RISKS_KEY in done:
|
|
141
|
+
return _strings(done[DONE_RISKS_KEY])
|
|
142
|
+
return tuple(verdict_risks)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def derive_task_results(
|
|
146
|
+
status: Status,
|
|
147
|
+
*,
|
|
148
|
+
branch: str | None,
|
|
149
|
+
base: str | None,
|
|
150
|
+
done: Mapping[str, object] | None,
|
|
151
|
+
report_commits: Sequence[Commit],
|
|
152
|
+
facts: str | None,
|
|
153
|
+
tests: ReportedTests | None,
|
|
154
|
+
pull_request: PullRequestLine | None,
|
|
155
|
+
scope_events: Sequence[Mapping[str, object]],
|
|
156
|
+
titles: Mapping[str, str],
|
|
157
|
+
verdict_risks: Sequence[str],
|
|
158
|
+
plan_summary: str | None,
|
|
159
|
+
commit_cap: int,
|
|
160
|
+
live: LiveWork | None = None,
|
|
161
|
+
live_error: str | None = None,
|
|
162
|
+
) -> TaskResults:
|
|
163
|
+
"""Derive the Results of a task from its recorded events, reports and facts snapshot.
|
|
164
|
+
|
|
165
|
+
A live observation of the task's worktree (`live`) is authoritative for its commits and
|
|
166
|
+
produced numbers, even when it finds none, so a report or facts snapshot that predates a
|
|
167
|
+
commit or revert never shows. Without one, a `TASK_DONE` that recorded the landed changes of
|
|
168
|
+
its integration is authoritative for the commits (even none) and the numbers, so neither report
|
|
169
|
+
commits nor the facts of the pre-integration branch show. Otherwise commits come from
|
|
170
|
+
`TASK_DONE` for a Done task,
|
|
171
|
+
else the latest report, else the facts; the first source with commits wins, and the numbers
|
|
172
|
+
come from the facts. Residual risks come from `TASK_DONE` when it recorded a risk list, even
|
|
173
|
+
an empty one, else from `verdict_risks`: the verdict of the task's current iteration. A new
|
|
174
|
+
task missing from `titles` keeps its id with no title.
|
|
175
|
+
"""
|
|
176
|
+
done_data = done or {}
|
|
177
|
+
landed = landed_changes(done_data) if live is None else None
|
|
178
|
+
if live is not None:
|
|
179
|
+
commits, produced = live.commits, live.produced
|
|
180
|
+
elif landed is not None:
|
|
181
|
+
commits = tuple(_from_oneline(line) for line in _strings(done_data.get(DONE_COMMITS_KEY)))
|
|
182
|
+
produced = ProducedNumbers(len(commits), *landed)
|
|
183
|
+
else:
|
|
184
|
+
candidates = (
|
|
185
|
+
tuple(_from_oneline(line) for line in _strings(done_data.get(DONE_COMMITS_KEY))),
|
|
186
|
+
tuple(
|
|
187
|
+
ResultCommit(commit.sha[:SHORT_SHA_LENGTH], commit.subject)
|
|
188
|
+
for commit in report_commits
|
|
189
|
+
),
|
|
190
|
+
tuple(_from_oneline(line) for line in facts_commit_lines(facts)),
|
|
191
|
+
)
|
|
192
|
+
commits = next((found for found in candidates if found), ())
|
|
193
|
+
produced = parse_produced(facts)
|
|
194
|
+
return TaskResults(
|
|
195
|
+
outcome=_outcome(status),
|
|
196
|
+
branch=branch,
|
|
197
|
+
base=base,
|
|
198
|
+
commits=commits[:commit_cap],
|
|
199
|
+
more_commits=max(len(commits) - commit_cap, 0),
|
|
200
|
+
produced=produced,
|
|
201
|
+
tests=tests,
|
|
202
|
+
pull_request=pull_request,
|
|
203
|
+
plan_changes=tuple(
|
|
204
|
+
PlanChange(
|
|
205
|
+
_strings(data.get(SCOPE_CHANGES_KEY)),
|
|
206
|
+
tuple(
|
|
207
|
+
(task_id, titles.get(task_id))
|
|
208
|
+
for task_id in _strings(data.get(NEW_TASK_IDS_KEY))
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
for data in scope_events
|
|
212
|
+
),
|
|
213
|
+
plan_summary=plan_summary,
|
|
214
|
+
residual_risks=_residual_risks(done_data, verdict_risks),
|
|
215
|
+
live_error=live_error,
|
|
216
|
+
squashed=landed is not None and done_data.get(DONE_INTEGRATION_KEY) == SQUASH_INTEGRATION,
|
|
217
|
+
)
|
shikiru/domain/runs.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Agent run model: one row per task role's session lineage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
|
|
8
|
+
from shikiru.agents.contracts import (
|
|
9
|
+
BILLING_MODE_UNKNOWN,
|
|
10
|
+
BILLING_SOURCE_UNKNOWN,
|
|
11
|
+
BillingMode,
|
|
12
|
+
BillingSource,
|
|
13
|
+
Usage,
|
|
14
|
+
)
|
|
15
|
+
from shikiru.domain.levels import displayed_pct
|
|
16
|
+
from shikiru.domain.tasks import DomainModel, Role
|
|
17
|
+
|
|
18
|
+
NO_USAGE = Usage(
|
|
19
|
+
input_tokens=0,
|
|
20
|
+
output_tokens=0,
|
|
21
|
+
cache_read_tokens=0,
|
|
22
|
+
cache_write_tokens=0,
|
|
23
|
+
cost_usd=None,
|
|
24
|
+
cost_basis="unknown",
|
|
25
|
+
context_used_pct=None,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RunStatus(StrEnum):
|
|
30
|
+
"""A run's lifecycle."""
|
|
31
|
+
|
|
32
|
+
LIVE = auto()
|
|
33
|
+
TAKEN_OVER = auto()
|
|
34
|
+
CLOSED = auto()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Run(DomainModel):
|
|
38
|
+
"""One agent session for a task role (mirrors the `runs` table)."""
|
|
39
|
+
|
|
40
|
+
id: int
|
|
41
|
+
task_id: str
|
|
42
|
+
role: Role
|
|
43
|
+
agent: str
|
|
44
|
+
model: str
|
|
45
|
+
effort: str | None = None
|
|
46
|
+
session_id: str | None = None
|
|
47
|
+
resume_command: str | None = None
|
|
48
|
+
last_message: str | None = None
|
|
49
|
+
status: RunStatus
|
|
50
|
+
usage: Usage = NO_USAGE
|
|
51
|
+
usage_reported: bool | None = None
|
|
52
|
+
billing_mode: BillingMode = BILLING_MODE_UNKNOWN
|
|
53
|
+
billing_source: BillingSource = BILLING_SOURCE_UNKNOWN
|
|
54
|
+
started_at: datetime
|
|
55
|
+
ended_at: datetime | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def has_positive_token_evidence(run: Run) -> bool:
|
|
59
|
+
"""Return whether `run` records a positive token count in any usage field."""
|
|
60
|
+
return any(
|
|
61
|
+
token > 0
|
|
62
|
+
for token in (
|
|
63
|
+
run.usage.input_tokens,
|
|
64
|
+
run.usage.output_tokens,
|
|
65
|
+
run.usage.cache_read_tokens,
|
|
66
|
+
run.usage.cache_write_tokens,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
MISSING_TITLE_VALUE = "\N{EN DASH}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def context_title(usage: Usage) -> str:
|
|
75
|
+
"""Format `usage`'s context share for a title (`42%`), or a dash if unreported."""
|
|
76
|
+
if usage.context_used_pct is None:
|
|
77
|
+
return MISSING_TITLE_VALUE
|
|
78
|
+
return f"{displayed_pct(usage.context_used_pct)}%"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cost_title(usage: Usage) -> str:
|
|
82
|
+
"""Format `usage`'s cost for a title (`$1.84`), or a dash if unreported."""
|
|
83
|
+
if usage.cost_usd is None:
|
|
84
|
+
return MISSING_TITLE_VALUE
|
|
85
|
+
return f"${usage.cost_usd:.2f}"
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Ready-set computation and dispatch gating."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
from graphlib import CycleError, TopologicalSorter
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from shikiru.domain.ladder import move_up, pick_rung
|
|
11
|
+
from shikiru.domain.steps import task_acting_role
|
|
12
|
+
from shikiru.domain.tasks import Role, Status
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
from shikiru.config.settings import Settings
|
|
19
|
+
from shikiru.domain.tasks import Task
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WaitReason(StrEnum):
|
|
23
|
+
"""Why a task is waiting instead of running."""
|
|
24
|
+
|
|
25
|
+
PAUSED = auto()
|
|
26
|
+
DEPENDENCY = auto()
|
|
27
|
+
SLOT = auto()
|
|
28
|
+
RESOURCES = auto()
|
|
29
|
+
QUOTA = auto()
|
|
30
|
+
PR_OPEN = "PR open"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TaskAgents:
|
|
35
|
+
"""The agent names a task would use for the Leader and the Implementer roles."""
|
|
36
|
+
|
|
37
|
+
leader: str
|
|
38
|
+
implementer: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class SchedulingGates:
|
|
43
|
+
"""Pure data the scheduler gathers before a dispatch pass."""
|
|
44
|
+
|
|
45
|
+
free_slots: frozenset[int]
|
|
46
|
+
resources_ok: bool
|
|
47
|
+
blocked_agents: frozenset[str]
|
|
48
|
+
agents: Mapping[str, TaskAgents]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Dispatch:
|
|
53
|
+
"""A task to dispatch into `slot`."""
|
|
54
|
+
|
|
55
|
+
task_id: str
|
|
56
|
+
slot: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ReadySchedule:
|
|
61
|
+
"""The result of one scheduling pass."""
|
|
62
|
+
|
|
63
|
+
dispatches: tuple[Dispatch, ...]
|
|
64
|
+
wait_reasons: Mapping[str, WaitReason]
|
|
65
|
+
dependency_cancelled: tuple[str, ...]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def find_dependency_cycle(dependencies: Mapping[str, Iterable[str]]) -> tuple[str, ...]:
|
|
69
|
+
"""Return the task ids on one dependency cycle (a self-edge included), or none."""
|
|
70
|
+
try:
|
|
71
|
+
TopologicalSorter(dependencies).prepare()
|
|
72
|
+
except CycleError as error:
|
|
73
|
+
return tuple(dict.fromkeys(error.args[1]))
|
|
74
|
+
return ()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def dispatch_sort_key(task: Task) -> tuple[bool, int, int]:
|
|
78
|
+
"""Order `task` the way dispatch does: requeued first, then priority, then submit order."""
|
|
79
|
+
return (task.requeued_at is None, -task.priority, task.seq)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def occupied_slot_ids(tasks: Sequence[Task]) -> frozenset[int]:
|
|
83
|
+
"""Return distinct slots held by running or integrating tasks."""
|
|
84
|
+
return frozenset(
|
|
85
|
+
task.slot
|
|
86
|
+
for task in tasks
|
|
87
|
+
if task.status in (Status.RUNNING, Status.INTEGRATING) and task.slot is not None
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def task_agents(task: Task, settings: Settings) -> TaskAgents:
|
|
92
|
+
"""Resolve the Leader and Implementer agents `task` would use for its next turns."""
|
|
93
|
+
return TaskAgents(
|
|
94
|
+
leader=pick_rung(task, Role.LEADER, settings).agent,
|
|
95
|
+
implementer=pick_rung(task, Role.IMPLEMENTER, settings).agent,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def quota_blocked_agents(agents: TaskAgents, blocked_until: Mapping[str, datetime]) -> list[str]:
|
|
100
|
+
"""Return each agent of `agents` that is blocked, once each, Leader first."""
|
|
101
|
+
return [
|
|
102
|
+
agent
|
|
103
|
+
for agent in dict.fromkeys((agents.leader, agents.implementer))
|
|
104
|
+
if agent in blocked_until
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def quota_blocking_agent(agents: TaskAgents, blocked_until: Mapping[str, datetime]) -> str | None:
|
|
109
|
+
"""Return the blocked agent of `agents` with the latest deadline; Leader wins ties."""
|
|
110
|
+
blocked = quota_blocked_agents(agents, blocked_until)
|
|
111
|
+
return max(blocked, key=blocked_until.__getitem__, default=None)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def quota_wait_reroutes(
|
|
115
|
+
task: Task,
|
|
116
|
+
settings: Settings,
|
|
117
|
+
blocked_until: Mapping[str, datetime],
|
|
118
|
+
wait_limit: datetime,
|
|
119
|
+
) -> bool:
|
|
120
|
+
"""Return whether the scheduler moves `task` to another agent: its block outlasts `wait_limit`.
|
|
121
|
+
|
|
122
|
+
A role moves when its resolved agent is blocked beyond `wait_limit` and a rung on a different,
|
|
123
|
+
unblocked agent exists, a higher one first, else one below; this is the rule the scheduler
|
|
124
|
+
applies to queued tasks.
|
|
125
|
+
"""
|
|
126
|
+
blocked_agents = frozenset(blocked_until)
|
|
127
|
+
for role in (Role.LEADER, Role.IMPLEMENTER):
|
|
128
|
+
until = blocked_until.get(pick_rung(task, role, settings).agent)
|
|
129
|
+
if until is None or until <= wait_limit:
|
|
130
|
+
continue
|
|
131
|
+
moved = move_up(task, role, settings, different_agent=True, blocked_agents=blocked_agents)
|
|
132
|
+
if moved is not None:
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def running_quota_wait_moves(
|
|
138
|
+
task: Task,
|
|
139
|
+
settings: Settings,
|
|
140
|
+
block_until: datetime,
|
|
141
|
+
budget_ends: datetime,
|
|
142
|
+
blocked_agents: frozenset[str],
|
|
143
|
+
) -> bool:
|
|
144
|
+
"""Return whether a running turn's quota wait moves `task` to another agent when it gives up.
|
|
145
|
+
|
|
146
|
+
The engine fixes the wait's deadline at `budget_ends` when the wait starts. It gives up only
|
|
147
|
+
if the block still outlasts that deadline, and then moves the acting role to a different agent
|
|
148
|
+
when one exists that is not in `blocked_agents`, the rule the engine's recovery applies.
|
|
149
|
+
"""
|
|
150
|
+
if block_until <= budget_ends:
|
|
151
|
+
return False
|
|
152
|
+
moved = move_up(
|
|
153
|
+
task,
|
|
154
|
+
task_acting_role(task),
|
|
155
|
+
settings,
|
|
156
|
+
different_agent=True,
|
|
157
|
+
blocked_agents=blocked_agents,
|
|
158
|
+
)
|
|
159
|
+
return moved is not None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _group_queued_tasks(
|
|
163
|
+
tasks: Sequence[Task], by_id: Mapping[str, Task]
|
|
164
|
+
) -> tuple[list[Task], dict[str, WaitReason], list[str]]:
|
|
165
|
+
ready: list[Task] = []
|
|
166
|
+
wait_reasons: dict[str, WaitReason] = {}
|
|
167
|
+
dependency_cancelled: list[str] = []
|
|
168
|
+
for task in tasks:
|
|
169
|
+
if task.status != Status.QUEUED:
|
|
170
|
+
continue
|
|
171
|
+
if task.held:
|
|
172
|
+
wait_reasons[task.id] = WaitReason.PAUSED
|
|
173
|
+
continue
|
|
174
|
+
deps = [by_id[dep_id] for dep_id in task.depends_on]
|
|
175
|
+
if any(dep.status == Status.CANCELLED for dep in deps):
|
|
176
|
+
dependency_cancelled.append(task.id)
|
|
177
|
+
continue
|
|
178
|
+
if all(dep.status == Status.DONE for dep in deps):
|
|
179
|
+
ready.append(task)
|
|
180
|
+
else:
|
|
181
|
+
wait_reasons[task.id] = WaitReason.DEPENDENCY
|
|
182
|
+
return ready, wait_reasons, dependency_cancelled
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def pick_ready(tasks: Sequence[Task], gates: SchedulingGates) -> ReadySchedule:
|
|
186
|
+
"""Return the ready set in dispatch order, gated by slots, resources and quota.
|
|
187
|
+
|
|
188
|
+
`tasks` is every task the caller tracks, so dependency statuses can be looked up by id; an
|
|
189
|
+
id that isn't in `tasks` is a caller bug and raises `KeyError`, as does a ready task missing
|
|
190
|
+
from `gates.agents`.
|
|
191
|
+
|
|
192
|
+
Ready tasks are ordered with `requeued_at` set first, then `priority` descending, then `seq`.
|
|
193
|
+
Slot and resources are gates shared by the whole pass: the first one that fails stops
|
|
194
|
+
dispatch there, and every task from that point on shares its `wait_reason`, since a
|
|
195
|
+
lower-priority task never dispatches ahead of a blocked one. Quota is scoped to one task at a
|
|
196
|
+
time (a quota block is the only cross-task effect, but it is still per-agent, not
|
|
197
|
+
per-pass): a quota-blocked task is skipped and dispatch continues with the next one.
|
|
198
|
+
"""
|
|
199
|
+
by_id = {task.id: task for task in tasks}
|
|
200
|
+
ready, wait_reasons, dependency_cancelled = _group_queued_tasks(tasks, by_id)
|
|
201
|
+
|
|
202
|
+
ordered = sorted(ready, key=dispatch_sort_key)
|
|
203
|
+
remaining_slots = set(gates.free_slots)
|
|
204
|
+
dispatches: list[Dispatch] = []
|
|
205
|
+
stopped_reason: WaitReason | None = None
|
|
206
|
+
|
|
207
|
+
for task in ordered:
|
|
208
|
+
if stopped_reason is not None:
|
|
209
|
+
wait_reasons[task.id] = stopped_reason
|
|
210
|
+
continue
|
|
211
|
+
if not remaining_slots:
|
|
212
|
+
stopped_reason = WaitReason.SLOT
|
|
213
|
+
wait_reasons[task.id] = stopped_reason
|
|
214
|
+
continue
|
|
215
|
+
if not gates.resources_ok:
|
|
216
|
+
stopped_reason = WaitReason.RESOURCES
|
|
217
|
+
wait_reasons[task.id] = stopped_reason
|
|
218
|
+
continue
|
|
219
|
+
agents = gates.agents[task.id]
|
|
220
|
+
if agents.leader in gates.blocked_agents or agents.implementer in gates.blocked_agents:
|
|
221
|
+
wait_reasons[task.id] = WaitReason.QUOTA
|
|
222
|
+
continue
|
|
223
|
+
slot = task.slot if task.slot in remaining_slots else min(remaining_slots)
|
|
224
|
+
remaining_slots.discard(slot)
|
|
225
|
+
dispatches.append(Dispatch(task_id=task.id, slot=slot))
|
|
226
|
+
|
|
227
|
+
return ReadySchedule(
|
|
228
|
+
dispatches=tuple(dispatches),
|
|
229
|
+
wait_reasons=wait_reasons,
|
|
230
|
+
dependency_cancelled=tuple(dependency_cancelled),
|
|
231
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Human project status task selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shikiru.domain.scheduling import dispatch_sort_key
|
|
8
|
+
from shikiru.domain.tasks import Status
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from shikiru.domain.tasks import Task
|
|
14
|
+
|
|
15
|
+
HUMAN_STATUS_TASK_LIMIT = 10
|
|
16
|
+
_OTHER_ACTIVE_STATUSES = (Status.RUNNING, Status.INTEGRATING)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def select_human_status_tasks(tasks: Sequence[Task]) -> tuple[Task, ...]:
|
|
20
|
+
"""Select at most ten tasks, reserving the final row for a latest recorded completion."""
|
|
21
|
+
escalated = sorted(
|
|
22
|
+
(task for task in tasks if task.status is Status.ESCALATED), key=lambda task: task.seq
|
|
23
|
+
)
|
|
24
|
+
active = sorted(
|
|
25
|
+
(task for task in tasks if task.status in _OTHER_ACTIVE_STATUSES),
|
|
26
|
+
key=lambda task: task.seq,
|
|
27
|
+
)
|
|
28
|
+
queued = sorted((task for task in tasks if task.status is Status.QUEUED), key=dispatch_sort_key)
|
|
29
|
+
completed = [task for task in tasks if task.status is Status.DONE]
|
|
30
|
+
with_finish_time = [task for task in completed if task.finished_at is not None]
|
|
31
|
+
done = (
|
|
32
|
+
max(with_finish_time, key=lambda task: (task.finished_at, task.seq))
|
|
33
|
+
if with_finish_time
|
|
34
|
+
else max(completed, key=lambda task: task.seq, default=None)
|
|
35
|
+
)
|
|
36
|
+
available_rows = HUMAN_STATUS_TASK_LIMIT - (done is not None)
|
|
37
|
+
selected = (escalated + active + queued)[:available_rows]
|
|
38
|
+
if done is not None:
|
|
39
|
+
selected.append(done)
|
|
40
|
+
return tuple(selected)
|