taskboy 0.1.1__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.
- taskboy/__init__.py +0 -0
- taskboy/adapters/__init__.py +0 -0
- taskboy/adapters/_util.py +30 -0
- taskboy/adapters/aws_read.py +107 -0
- taskboy/adapters/confluence.py +88 -0
- taskboy/adapters/github_api.py +564 -0
- taskboy/adapters/issues.py +435 -0
- taskboy/adapters/jira.py +263 -0
- taskboy/adapters/sentry.py +82 -0
- taskboy/adapters/slack_history.py +204 -0
- taskboy/assets.py +27 -0
- taskboy/audit.py +78 -0
- taskboy/broker.py +266 -0
- taskboy/classifier.py +219 -0
- taskboy/cli.py +161 -0
- taskboy/config.py +539 -0
- taskboy/dashboard/__init__.py +5 -0
- taskboy/dashboard/api.py +963 -0
- taskboy/dashboard/app.py +78 -0
- taskboy/dashboard/auth.py +117 -0
- taskboy/dashboard/editors.py +140 -0
- taskboy/dashboard/gitops.py +60 -0
- taskboy/dashboard/render.py +48 -0
- taskboy/debug_feed.py +170 -0
- taskboy/deploy/env.example +17 -0
- taskboy/deploy/git-cred-helper.py +36 -0
- taskboy/deploy/install.sh +101 -0
- taskboy/deploy/remote-update.sh +52 -0
- taskboy/deploy/taskboy-restart.path +10 -0
- taskboy/deploy/taskboy-restart.service +8 -0
- taskboy/deploy/taskboy.service +22 -0
- taskboy/hooks.py +208 -0
- taskboy/issue_runs.py +203 -0
- taskboy/llm.py +125 -0
- taskboy/main.py +269 -0
- taskboy/memory.py +59 -0
- taskboy/models.py +103 -0
- taskboy/mrkdwn.py +19 -0
- taskboy/notify.py +41 -0
- taskboy/orchestrator.py +401 -0
- taskboy/personality.py +17 -0
- taskboy/prompts.py +288 -0
- taskboy/quick.py +234 -0
- taskboy/redact.py +49 -0
- taskboy/repocache.py +117 -0
- taskboy/review_requests.py +388 -0
- taskboy/router.py +146 -0
- taskboy/runner.py +581 -0
- taskboy/scheduler.py +301 -0
- taskboy/secrets.py +83 -0
- taskboy/settings.py +19 -0
- taskboy/setup_checks.py +184 -0
- taskboy/setup_wizard.py +699 -0
- taskboy/skills.py +95 -0
- taskboy/slack.py +666 -0
- taskboy/slack_users.py +32 -0
- taskboy/started_messages.py +33 -0
- taskboy/store.py +1393 -0
- taskboy/task_actions.py +86 -0
- taskboy/templates/config.example.yaml +149 -0
- taskboy/templates/conventions.md +60 -0
- taskboy/templates/help.example.md +29 -0
- taskboy/templates/personality_agent.example.md +5 -0
- taskboy/templates/personality_reviewer.example.md +5 -0
- taskboy/templates/services/aws.yaml +7 -0
- taskboy/templates/services/confluence.yaml +4 -0
- taskboy/templates/services/github.yaml +14 -0
- taskboy/templates/services/jira.yaml +6 -0
- taskboy/templates/services/sentry.yaml +4 -0
- taskboy/templates/services/slack.yaml +9 -0
- taskboy/templates/skills/README.md +33 -0
- taskboy/templates/skills/discoverissues/SKILL.md +46 -0
- taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
- taskboy/templates/skills/jira2pr/SKILL.md +66 -0
- taskboy/templates/skills/monitor/SKILL.md +32 -0
- taskboy/templates/skills/monitornew/SKILL.md +27 -0
- taskboy/templates/skills/refineissue/SKILL.md +35 -0
- taskboy/templates/skills/release/SKILL.md +36 -0
- taskboy/templates/skills/review/SKILL.md +89 -0
- taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
- taskboy/templates/skills/reviews/SKILL.md +26 -0
- taskboy/templates/skills/slack2jira/SKILL.md +47 -0
- taskboy/templates/skills/slack2pr/SKILL.md +64 -0
- taskboy/templates/skills/spec2pr/SKILL.md +35 -0
- taskboy/templates/slack_app_manifest.yaml +48 -0
- taskboy/templates/task_started_messages.yaml +14 -0
- taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
- taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
- taskboy/ui_dist/index.html +15 -0
- taskboy/workspace.py +86 -0
- taskboy-0.1.1.dist-info/METADATA +120 -0
- taskboy-0.1.1.dist-info/RECORD +95 -0
- taskboy-0.1.1.dist-info/WHEEL +5 -0
- taskboy-0.1.1.dist-info/entry_points.txt +2 -0
- taskboy-0.1.1.dist-info/top_level.txt +1 -0
taskboy/repocache.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""best-effort bare mirrors for fast task workspace seeding."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from taskboy.redact import redactor
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("taskboy.repocache")
|
|
13
|
+
|
|
14
|
+
MIN_FREE_GB = 10
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mirror_path(repos_root, repo) -> Path:
|
|
18
|
+
org, name = repo.split("/", 1)
|
|
19
|
+
return Path(repos_root) / org / f"{name}.git"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def disk_ok(repos_root, min_free_gb=MIN_FREE_GB) -> bool:
|
|
23
|
+
root = Path(repos_root)
|
|
24
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
return shutil.disk_usage(root).free >= min_free_gb * 1024**3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def refresh_all(store, broker, repos_root, approved_repos) -> dict:
|
|
29
|
+
_ = store # housekeeping has no synthetic task id to attach an audit event to
|
|
30
|
+
root = Path(repos_root)
|
|
31
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
if not disk_ok(root):
|
|
33
|
+
free_gb = shutil.disk_usage(root).free / 1024**3
|
|
34
|
+
logger.warning("repo cache refresh skipped: %.1f GB free is below the %s GB guard", free_gb, MIN_FREE_GB)
|
|
35
|
+
return {"skipped": True, "free_gb": free_gb}
|
|
36
|
+
result: dict = {"pruned": prune_removed(root, approved_repos)}
|
|
37
|
+
for repo in approved_repos:
|
|
38
|
+
try:
|
|
39
|
+
result[repo] = await refresh_one(broker, root, repo)
|
|
40
|
+
except Exception:
|
|
41
|
+
logger.exception("repo mirror refresh failed for %s", repo)
|
|
42
|
+
result[repo] = False
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def refresh_one(broker, repos_root, repo, timeout=300) -> bool:
|
|
47
|
+
path = mirror_path(repos_root, repo)
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
token, _ = await broker.read_token([repo])
|
|
50
|
+
basic = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
|
51
|
+
redactor.register(basic)
|
|
52
|
+
try:
|
|
53
|
+
env = os.environ.copy()
|
|
54
|
+
env.update(
|
|
55
|
+
{
|
|
56
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
57
|
+
"GIT_CONFIG_COUNT": "1",
|
|
58
|
+
"GIT_CONFIG_KEY_0": "http.https://github.com/.extraheader",
|
|
59
|
+
"GIT_CONFIG_VALUE_0": f"Authorization: Basic {basic}",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
existed = path.exists()
|
|
63
|
+
try:
|
|
64
|
+
if existed:
|
|
65
|
+
await _run_git(["-C", str(path), "fetch", "--prune"], env=env, timeout=timeout)
|
|
66
|
+
else:
|
|
67
|
+
await _run_git(["clone", "--mirror", f"https://github.com/{repo}.git", str(path)], env=env, timeout=timeout)
|
|
68
|
+
return True
|
|
69
|
+
except Exception:
|
|
70
|
+
logger.exception("repo mirror refresh failed for %s", repo)
|
|
71
|
+
if not existed and path.exists():
|
|
72
|
+
shutil.rmtree(path)
|
|
73
|
+
return False
|
|
74
|
+
finally:
|
|
75
|
+
redactor.unregister(basic)
|
|
76
|
+
redactor.unregister(token)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def prune_removed(repos_root, approved_repos) -> int:
|
|
80
|
+
root = Path(repos_root)
|
|
81
|
+
approved = {mirror_path(root, repo) for repo in approved_repos}
|
|
82
|
+
removed = 0
|
|
83
|
+
for path in root.glob("*/*.git") if root.exists() else []:
|
|
84
|
+
if path not in approved:
|
|
85
|
+
shutil.rmtree(path)
|
|
86
|
+
removed += 1
|
|
87
|
+
return removed
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def clone_from_mirror(repos_root, repo, dest: Path) -> bool:
|
|
91
|
+
source = mirror_path(repos_root, repo)
|
|
92
|
+
if not source.exists():
|
|
93
|
+
return False
|
|
94
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
try:
|
|
96
|
+
await _run_git(["clone", str(source), str(dest)], timeout=60)
|
|
97
|
+
await _run_git(["-C", str(dest), "remote", "set-url", "origin", f"https://github.com/{repo}.git"], timeout=60)
|
|
98
|
+
return True
|
|
99
|
+
except Exception:
|
|
100
|
+
logger.exception("workspace clone from mirror failed for %s", repo)
|
|
101
|
+
if dest.exists():
|
|
102
|
+
shutil.rmtree(dest)
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _run_git(args, env=None, timeout=300):
|
|
107
|
+
process = await asyncio.create_subprocess_exec("git", *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env)
|
|
108
|
+
try:
|
|
109
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
|
110
|
+
except TimeoutError:
|
|
111
|
+
process.kill()
|
|
112
|
+
await process.wait()
|
|
113
|
+
raise RuntimeError("git command timed out")
|
|
114
|
+
if process.returncode:
|
|
115
|
+
message = redactor.redact(stderr.decode(errors="replace"))[:500]
|
|
116
|
+
raise RuntimeError(f"git command failed ({process.returncode}): {message}")
|
|
117
|
+
return stdout.decode(errors="replace")
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""poll github review requests and turn app-reviewer requests into /review tasks."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
from taskboy.adapters.github_api import GitHubStatusError
|
|
9
|
+
from taskboy.orchestrator import accept_task
|
|
10
|
+
from taskboy.redact import redactor
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("taskboy.review_requests")
|
|
13
|
+
|
|
14
|
+
GITHUB_API = "https://api.github.com"
|
|
15
|
+
TOKEN_REFRESH_MARGIN_SECONDS = 600
|
|
16
|
+
TRANSIENT_RETRY_DELAY_SECONDS = 2
|
|
17
|
+
DEBUG_PAGE_AFTER_CONSECUTIVE_FAILURES = 3
|
|
18
|
+
SECONDARY_RATE_LIMIT_STATUSES = {403, 429} # github's abuse/secondary-rate-limit responses always carry Retry-After
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_transient(error: Exception) -> bool:
|
|
22
|
+
"""github blips the next sweep will heal: connection problems, timeouts, 5xx, and secondary rate limits."""
|
|
23
|
+
import aiohttp
|
|
24
|
+
|
|
25
|
+
if isinstance(error, (aiohttp.ClientError, asyncio.TimeoutError)):
|
|
26
|
+
return True
|
|
27
|
+
if isinstance(error, GitHubStatusError):
|
|
28
|
+
if error.status >= 500:
|
|
29
|
+
return True
|
|
30
|
+
# a plain 403 can also mean "permission denied" (not transient) — Retry-After is what tells them apart
|
|
31
|
+
if error.status in SECONDARY_RATE_LIMIT_STATUSES and error.retry_after is not None:
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _should_page_debug(count: int, transient: bool) -> bool:
|
|
37
|
+
"""non-transient failures page at count 1, then both kinds join the N, N**2, N**3, ... progression."""
|
|
38
|
+
if not transient and count == 1:
|
|
39
|
+
return True
|
|
40
|
+
power = DEBUG_PAGE_AFTER_CONSECUTIVE_FAILURES
|
|
41
|
+
while power < count:
|
|
42
|
+
power *= DEBUG_PAGE_AFTER_CONSECUTIVE_FAILURES
|
|
43
|
+
return power == count
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _failure_signature(error: Exception) -> str:
|
|
47
|
+
"""identifies an error's *shape*, so a shape change gets its own error row without resetting the streak count."""
|
|
48
|
+
if isinstance(error, GitHubStatusError):
|
|
49
|
+
return f"http:{error.status}"
|
|
50
|
+
return type(error).__name__
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ReviewRequestPoller:
|
|
54
|
+
def __init__(self, store, config, broker, notifier, reviewer_broker=None):
|
|
55
|
+
self.store = store
|
|
56
|
+
self.config = config
|
|
57
|
+
self.broker = broker
|
|
58
|
+
self.reviewer_broker = reviewer_broker
|
|
59
|
+
self.notifier = notifier
|
|
60
|
+
github = config.raw.get("github") or {}
|
|
61
|
+
review_requests = github.get("review_requests") or {}
|
|
62
|
+
self.enabled = bool(review_requests.get("enabled", False))
|
|
63
|
+
self.poll_interval_seconds = max(int(review_requests.get("poll_interval_seconds", 60)), 1)
|
|
64
|
+
self.notify_channel = str(review_requests.get("notify_channel") or "")
|
|
65
|
+
self.auto_address_agent_prs = bool(review_requests.get("auto_address_agent_prs", False))
|
|
66
|
+
if self.auto_address_agent_prs and (reviewer_broker is None or not config.reviewer.enabled or not config.reviewer.review_agent_prs):
|
|
67
|
+
# the loop needs an enabled reviewer reviewing the main agent's pushes; anything less leaves the flag silently doing nothing
|
|
68
|
+
logger.warning("github.review_requests.auto_address_agent_prs requires an enabled reviewer with reviewer.review_agent_prs — forcing it off")
|
|
69
|
+
self.auto_address_agent_prs = False
|
|
70
|
+
self.round_cap = max(int(review_requests.get("round_cap", 10)), 1)
|
|
71
|
+
self.approved_repos = list(github.get("approved_repos") or [])
|
|
72
|
+
self.bot_login: str | None = None
|
|
73
|
+
self.reviewer_bot_login: str | None = None
|
|
74
|
+
self.token: str | None = None
|
|
75
|
+
self.token_expires_at = 0.0
|
|
76
|
+
self.etags: dict[str, str] = {}
|
|
77
|
+
self.consecutive_failures: dict[str, int] = {}
|
|
78
|
+
self.failure_signatures: dict[str, str] = {}
|
|
79
|
+
|
|
80
|
+
async def run(self) -> None:
|
|
81
|
+
while True:
|
|
82
|
+
try:
|
|
83
|
+
if self.bot_login is None:
|
|
84
|
+
self.bot_login = f"{await self.broker.app_slug()}[bot]"
|
|
85
|
+
if self.reviewer_broker is not None and self.reviewer_bot_login is None:
|
|
86
|
+
self.reviewer_bot_login = f"{await self.reviewer_broker.app_slug()}[bot]"
|
|
87
|
+
await self.sweep()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
self.store.add_error("review_poller", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "sweep"})
|
|
90
|
+
debug = getattr(self.notifier, "debug", None)
|
|
91
|
+
if debug is not None:
|
|
92
|
+
await debug.system_error("review_poller", str(e))
|
|
93
|
+
logger.exception("github review-request sweep failed")
|
|
94
|
+
await asyncio.sleep(self.poll_interval_seconds)
|
|
95
|
+
|
|
96
|
+
async def sweep(self) -> None:
|
|
97
|
+
if not self.enabled:
|
|
98
|
+
return
|
|
99
|
+
if self.bot_login is None:
|
|
100
|
+
self.bot_login = f"{await self.broker.app_slug()}[bot]"
|
|
101
|
+
if self.reviewer_broker is not None and self.reviewer_bot_login is None:
|
|
102
|
+
self.reviewer_bot_login = f"{await self.reviewer_broker.app_slug()}[bot]"
|
|
103
|
+
token = await self._token()
|
|
104
|
+
reminted = False # at most one inline re-mint per sweep
|
|
105
|
+
for repo in self.approved_repos:
|
|
106
|
+
try:
|
|
107
|
+
await self._sweep_repo(repo, token)
|
|
108
|
+
self.consecutive_failures.pop(repo, None)
|
|
109
|
+
self.failure_signatures.pop(repo, None)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
abort_sweep = False
|
|
112
|
+
signature = _failure_signature(e)
|
|
113
|
+
first_failure_this_streak = self.failure_signatures.get(repo) != signature
|
|
114
|
+
if isinstance(e, GitHubStatusError) and (e.status == 401 or (e.status == 403 and e.retry_after is None)):
|
|
115
|
+
abort_sweep = e.status == 401
|
|
116
|
+
# clear so a healed permission is picked up next sweep, but never discard a token this sweep already minted for another repo
|
|
117
|
+
if abort_sweep or not reminted:
|
|
118
|
+
self.token, self.token_expires_at = None, 0.0
|
|
119
|
+
if first_failure_this_streak and not reminted:
|
|
120
|
+
reminted = True
|
|
121
|
+
try:
|
|
122
|
+
token = await self._token() # re-mints immediately
|
|
123
|
+
await self._sweep_repo(repo, token)
|
|
124
|
+
self.consecutive_failures.pop(repo, None)
|
|
125
|
+
self.failure_signatures.pop(repo, None)
|
|
126
|
+
logger.warning("github rejected the cached review-poller token (%s) for %s — re-minted and recovered in-sweep", e.status, repo)
|
|
127
|
+
continue
|
|
128
|
+
except Exception as retry_error: # mint failed or the repo failed again post-mint
|
|
129
|
+
e = retry_error
|
|
130
|
+
signature = _failure_signature(e)
|
|
131
|
+
first_failure_this_streak = self.failure_signatures.get(repo) != signature
|
|
132
|
+
if abort_sweep:
|
|
133
|
+
self.token, self.token_expires_at = None, 0.0
|
|
134
|
+
self.failure_signatures[repo] = signature
|
|
135
|
+
count = self.consecutive_failures.get(repo, 0) + 1
|
|
136
|
+
self.consecutive_failures[repo] = count
|
|
137
|
+
if first_failure_this_streak:
|
|
138
|
+
# count keeps climbing across a shape change so a flapping error can't re-arm the page-on-first rule
|
|
139
|
+
self.store.add_error("review_poller", type(e).__name__, str(e), traceback="".join(traceback.format_exception(e)), context={"repository": repo})
|
|
140
|
+
if _should_page_debug(count, _is_transient(e)):
|
|
141
|
+
debug = getattr(self.notifier, "debug", None)
|
|
142
|
+
if debug is not None:
|
|
143
|
+
await debug.system_error("review_poller", f"{repo}: {e}")
|
|
144
|
+
# exc_info=e (not logger.exception, which reads sys.exc_info()) so a re-mint retry that reassigned
|
|
145
|
+
# `e` above logs the same exception that was just recorded to sqlite, not the pre-retry one
|
|
146
|
+
logger.error("github review-request poll failed for %s", repo, exc_info=e)
|
|
147
|
+
if abort_sweep:
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
async def _sweep_repo(self, repo: str, token: str) -> None:
|
|
151
|
+
pulls = await self._list_open_pulls(repo, token)
|
|
152
|
+
if pulls is None:
|
|
153
|
+
return # a real 304 on page 1 — nothing changed since the last sweep
|
|
154
|
+
# _list_open_pulls always walks every page, so `pulls` is the complete open-pulls set —
|
|
155
|
+
# a missing number here is genuinely closed/merged, never just pushed off page 1 (issue #88)
|
|
156
|
+
self._cleanup_followup_meta_for_closed_prs(repo, {int(pr["number"]) for pr in pulls})
|
|
157
|
+
for pr in pulls:
|
|
158
|
+
reviewers = [(reviewer or {}).get("login") for reviewer in pr.get("requested_reviewers") or []]
|
|
159
|
+
number = int(pr["number"])
|
|
160
|
+
head_sha = str((pr.get("head") or {})["sha"])
|
|
161
|
+
main_requested = self.bot_login in reviewers
|
|
162
|
+
author_login = str((pr.get("user") or {}).get("login") or "")
|
|
163
|
+
reviewer_requested = self.reviewer_bot_login is not None and self.reviewer_bot_login in reviewers
|
|
164
|
+
review_agent_pr = self.config.reviewer.review_agent_prs and author_login == self.bot_login
|
|
165
|
+
reviewer_available = self.reviewer_broker is not None and self.config.reviewer.enabled
|
|
166
|
+
if reviewer_available and (main_requested or reviewer_requested or review_agent_pr):
|
|
167
|
+
if self.store.has_active_main_task_referencing(pr["html_url"]):
|
|
168
|
+
logger.info("skipping reviewer task for %s#%d — an active main-agent task already references this PR", repo, number)
|
|
169
|
+
continue
|
|
170
|
+
reviewer_key = self._reviewer_key(repo, number, head_sha)
|
|
171
|
+
task, status = await accept_task(
|
|
172
|
+
self.store,
|
|
173
|
+
self.config,
|
|
174
|
+
self.notifier,
|
|
175
|
+
team_id="github",
|
|
176
|
+
channel_id=self.notify_channel,
|
|
177
|
+
thread_ts=reviewer_key,
|
|
178
|
+
message_ts=reviewer_key,
|
|
179
|
+
user_id="github",
|
|
180
|
+
text=f"/review {pr['html_url']}",
|
|
181
|
+
persona="reviewer",
|
|
182
|
+
)
|
|
183
|
+
if status == "created":
|
|
184
|
+
logger.info("created reviewer github review task %s for %s", task.task_id if task else "", reviewer_key)
|
|
185
|
+
elif status == "queue_full":
|
|
186
|
+
logger.warning("reviewer github review task refused because the queue is full: %s", reviewer_key)
|
|
187
|
+
elif main_requested:
|
|
188
|
+
key = f"{repo}#{number}@{head_sha}"
|
|
189
|
+
task, status = await accept_task(
|
|
190
|
+
self.store,
|
|
191
|
+
self.config,
|
|
192
|
+
self.notifier,
|
|
193
|
+
team_id="github",
|
|
194
|
+
channel_id=self.notify_channel,
|
|
195
|
+
thread_ts=key,
|
|
196
|
+
message_ts=key,
|
|
197
|
+
user_id="github",
|
|
198
|
+
text=f"/review {pr['html_url']}",
|
|
199
|
+
)
|
|
200
|
+
if status == "created":
|
|
201
|
+
if self.config.reviewer.enabled:
|
|
202
|
+
logger.warning("reviewer is unavailable; falling back to the main agent with GitHub review task %s for %s", task.task_id if task else "", key)
|
|
203
|
+
else:
|
|
204
|
+
logger.info("created github review task %s for %s", task.task_id if task else "", key)
|
|
205
|
+
elif status == "queue_full":
|
|
206
|
+
logger.warning("github review task refused because the queue is full: %s", key)
|
|
207
|
+
if self.auto_address_agent_prs and reviewer_available and author_login == self.bot_login:
|
|
208
|
+
await self._maybe_follow_up_agent(repo, pr, number, head_sha, token)
|
|
209
|
+
|
|
210
|
+
async def _list_open_pulls(self, repo: str, token: str) -> list[dict] | None:
|
|
211
|
+
"""page through open PRs at 100/page until a short page ends the walk (mirrors `_get_reviews`)."""
|
|
212
|
+
page1_path = f"/repos/{repo}/pulls?state=open&per_page=100&page=1"
|
|
213
|
+
status, headers, page_pulls = await self._get_with_retry(page1_path, token)
|
|
214
|
+
if status == 304:
|
|
215
|
+
return None
|
|
216
|
+
page_pulls = page_pulls or []
|
|
217
|
+
etag = headers.get("ETag") or headers.get("etag")
|
|
218
|
+
# don't cache while auto-follow-up is on: a 304 would skip the per-PR stall check
|
|
219
|
+
# don't cache a full page 1 either: a 304 would exit before page 2 (issue #88)
|
|
220
|
+
if etag and not self.auto_address_agent_prs and len(page_pulls) < 100:
|
|
221
|
+
self.etags[page1_path] = str(etag)
|
|
222
|
+
pulls = list(page_pulls)
|
|
223
|
+
page = 1
|
|
224
|
+
while len(page_pulls) == 100:
|
|
225
|
+
page += 1
|
|
226
|
+
path = f"/repos/{repo}/pulls?state=open&per_page=100&page={page}"
|
|
227
|
+
_, _, page_pulls = await self._get_with_retry(path, token)
|
|
228
|
+
page_pulls = page_pulls or []
|
|
229
|
+
pulls.extend(page_pulls)
|
|
230
|
+
return pulls
|
|
231
|
+
|
|
232
|
+
async def _maybe_follow_up_agent(self, repo: str, pr: dict, number: int, head_sha: str, token: str) -> None:
|
|
233
|
+
"""when the reviewer has reviewed the current push of an agent-authored PR and hasn't approved, spawn the main
|
|
234
|
+
agent to address it — up to `round_cap` rounds, then stop and ping `notify_channel` once instead of pinging every sweep."""
|
|
235
|
+
html_url = pr["html_url"]
|
|
236
|
+
if self.store.has_active_main_task_referencing(html_url, include_reviewer=True):
|
|
237
|
+
return # a main or reviewer task is already in flight for this pr — the reviewer's review task may still be running
|
|
238
|
+
round_key = f"review_followup_round:{repo}#{number}"
|
|
239
|
+
capped_key = f"review_followup_capped:{repo}#{number}"
|
|
240
|
+
stalled_key = f"review_followup_stalled:{repo}#{number}@{head_sha}"
|
|
241
|
+
|
|
242
|
+
capped_at_sha = self.store.meta_get(capped_key)
|
|
243
|
+
if capped_at_sha is not None:
|
|
244
|
+
if capped_at_sha in (head_sha, "1"):
|
|
245
|
+
# "1" is the legacy sentinel from before this row stored a head_sha (pre-existing capped rows
|
|
246
|
+
# at deploy time) — treat it as still capped rather than re-arming with no push having landed
|
|
247
|
+
return # still capped for this exact push — stop paying a paged reviews GET per sweep
|
|
248
|
+
# a new push landed after the cap fired: a human intervened (pushed a fix, re-requested review),
|
|
249
|
+
# so give the loop a fresh round budget for it instead of leaving it dead for the pr's whole life
|
|
250
|
+
self.store.meta_delete(capped_key)
|
|
251
|
+
self.store.meta_delete(round_key)
|
|
252
|
+
|
|
253
|
+
reviews = await self._get_reviews(repo, number, token)
|
|
254
|
+
reviewer_reviews = [review for review in reviews if (review.get("user") or {}).get("login") == self.reviewer_bot_login and review.get("state") in {"APPROVED", "CHANGES_REQUESTED"}]
|
|
255
|
+
latest = reviewer_reviews[-1] if reviewer_reviews else None # github returns reviews in submission order
|
|
256
|
+
if latest is not None and latest.get("state") == "APPROVED":
|
|
257
|
+
return # the reviewer is satisfied — the loop is done (issue #75)
|
|
258
|
+
if latest is None or latest.get("commit_id") != head_sha:
|
|
259
|
+
# no reviewer review of the current push: normally one is just in flight, but a reviewer task that
|
|
260
|
+
# died or parked without posting would leave this branch returning forever with no escalation.
|
|
261
|
+
# the guard at the top already returned for an active task, so any task found here is stalled.
|
|
262
|
+
reviewer_task = self.store.task_by_intake_key("github", self.notify_channel, self._reviewer_key(repo, number, head_sha))
|
|
263
|
+
if reviewer_task is not None:
|
|
264
|
+
await self._escalate_once(
|
|
265
|
+
stalled_key,
|
|
266
|
+
"review follow-up loop stalled on %s — the reviewer's review task %s is %s without posting a review" % (html_url, reviewer_task.task_id, reviewer_task.state),
|
|
267
|
+
f"{self._loop_name} review loop on {html_url} stalled — {self.config.reviewer.name}'s review task is {reviewer_task.state} without posting a review, so the loop can't advance — needs a human look.",
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
# count rounds this poller actually created, not every reviewer CHANGES_REQUESTED ever (pre-flag or /review runs)
|
|
271
|
+
round_number = int(self.store.meta_get(round_key) or "0") + 1
|
|
272
|
+
if round_number > self.round_cap:
|
|
273
|
+
await self._escalate_once(
|
|
274
|
+
capped_key,
|
|
275
|
+
f"review follow-up loop hit its {self.round_cap}-round cap on {html_url} — stopping and escalating",
|
|
276
|
+
f"{self._loop_name} review loop on {html_url} hit its {self.round_cap}-round cap without {self.config.reviewer.name} approving — needs a human look.",
|
|
277
|
+
value=head_sha,
|
|
278
|
+
)
|
|
279
|
+
return
|
|
280
|
+
key = f"{repo}#{number}@{head_sha}:review:{latest.get('id')}"
|
|
281
|
+
branch = str((pr.get("head") or {}).get("ref") or "")
|
|
282
|
+
task, status = await accept_task(
|
|
283
|
+
self.store,
|
|
284
|
+
self.config,
|
|
285
|
+
self.notifier,
|
|
286
|
+
team_id="github",
|
|
287
|
+
channel_id=self.notify_channel,
|
|
288
|
+
thread_ts=key,
|
|
289
|
+
message_ts=key,
|
|
290
|
+
user_id="github",
|
|
291
|
+
text=f"address review comments on {html_url} — push the fix to the existing branch `{branch}`, not a new one",
|
|
292
|
+
)
|
|
293
|
+
if status == "created":
|
|
294
|
+
self.store.meta_set(round_key, str(round_number))
|
|
295
|
+
self.store.meta_delete(stalled_key) # forward progress made — clear any stale stall marker for this head
|
|
296
|
+
logger.info("created follow-up task %s for %s (round %d/%d)", task.task_id if task else "", key, round_number, self.round_cap)
|
|
297
|
+
elif status == "queue_full":
|
|
298
|
+
logger.warning("follow-up task refused because the queue is full: %s", key)
|
|
299
|
+
elif status == "duplicate" and task is not None:
|
|
300
|
+
# terminal task on an unchanged key would dedup forever — escalate instead
|
|
301
|
+
await self._escalate_once(
|
|
302
|
+
stalled_key,
|
|
303
|
+
"review follow-up loop stalled on %s — the follow-up task %s finished (%s) without a new push" % (html_url, task.task_id, task.state),
|
|
304
|
+
f"{self._loop_name} review loop on {html_url} stalled — {self.config.agent_name}'s last follow-up task finished ({task.state}) without pushing a fix, so {self.config.reviewer.name} never re-reviewed — needs a human look.",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def _loop_name(self) -> str:
|
|
309
|
+
return f"{self.config.agent_name}↔{self.config.reviewer.name}"
|
|
310
|
+
|
|
311
|
+
def _reviewer_key(self, repo: str, number: int, head_sha: str) -> str:
|
|
312
|
+
# also the poller's intake message_ts, so _maybe_follow_up_agent can look the reviewer task back up
|
|
313
|
+
return f"reviewer:{repo}#{number}@{head_sha}"
|
|
314
|
+
|
|
315
|
+
async def _escalate_once(self, escalation_key: str, log_message: str, notify_message: str, value: str = "1") -> None:
|
|
316
|
+
"""fire a warning + a single human-facing notification per `escalation_key`, falling back to the debug
|
|
317
|
+
notifier when no `notify_channel` is configured — otherwise the escalation is a log line no human sees."""
|
|
318
|
+
if self.store.meta_get(escalation_key) is not None:
|
|
319
|
+
return
|
|
320
|
+
logger.warning(log_message)
|
|
321
|
+
if self.notify_channel:
|
|
322
|
+
await self.notifier.answer(self.notify_channel, None, notify_message)
|
|
323
|
+
else:
|
|
324
|
+
debug = getattr(self.notifier, "debug", None)
|
|
325
|
+
if debug is not None:
|
|
326
|
+
posted = await debug.system_error("review_poller", notify_message)
|
|
327
|
+
if not posted:
|
|
328
|
+
return # suppressed by the cooldown — nothing landed
|
|
329
|
+
# marked sent only after the notification lands — a failed send must retry on the next sweep
|
|
330
|
+
self.store.meta_set(escalation_key, value)
|
|
331
|
+
|
|
332
|
+
def _cleanup_followup_meta_for_closed_prs(self, repo: str, open_numbers: set[int]) -> None:
|
|
333
|
+
"""sweep meta for any pr this repo still has follow-up rows for but that is no longer in the open list."""
|
|
334
|
+
for key in self.store.meta_keys_with_prefix("review_followup_"):
|
|
335
|
+
# key shapes: review_followup_{round,capped}:{repo}#{number}, review_followup_stalled:{repo}#{number}@{sha}
|
|
336
|
+
_, _, rest = key.partition(":")
|
|
337
|
+
pr_repo, _, suffix = rest.partition("#")
|
|
338
|
+
if pr_repo != repo:
|
|
339
|
+
continue
|
|
340
|
+
number_part = suffix.split("@", 1)[0]
|
|
341
|
+
if number_part.isdigit() and int(number_part) not in open_numbers:
|
|
342
|
+
self.store.meta_delete(key)
|
|
343
|
+
|
|
344
|
+
async def _get_reviews(self, repo: str, number: int, token: str) -> list[dict]:
|
|
345
|
+
reviews: list[dict] = []
|
|
346
|
+
page = 1
|
|
347
|
+
while True:
|
|
348
|
+
path = f"/repos/{repo}/pulls/{number}/reviews?per_page=100&page={page}"
|
|
349
|
+
_, _, page_reviews = await self._get_with_retry(path, token)
|
|
350
|
+
page_reviews = page_reviews or []
|
|
351
|
+
reviews.extend(page_reviews)
|
|
352
|
+
if len(page_reviews) < 100:
|
|
353
|
+
return reviews
|
|
354
|
+
page += 1
|
|
355
|
+
|
|
356
|
+
async def _token(self) -> str:
|
|
357
|
+
if self.token is None or self.token_expires_at - time.time() < TOKEN_REFRESH_MARGIN_SECONDS:
|
|
358
|
+
self.token, self.token_expires_at = await self.broker.read_token(self.approved_repos, permissions={"pull_requests": "read", "metadata": "read"})
|
|
359
|
+
assert self.token is not None
|
|
360
|
+
return self.token
|
|
361
|
+
|
|
362
|
+
async def _get_with_retry(self, path: str, token: str):
|
|
363
|
+
try:
|
|
364
|
+
return await self._get(path, token)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
if not _is_transient(e):
|
|
367
|
+
raise
|
|
368
|
+
logger.warning("transient github error for %s, retrying once: %s", path, e)
|
|
369
|
+
await asyncio.sleep(TRANSIENT_RETRY_DELAY_SECONDS)
|
|
370
|
+
return await self._get(path, token)
|
|
371
|
+
|
|
372
|
+
async def _get(self, path: str, token: str):
|
|
373
|
+
"""the github http seam — patched in unit tests."""
|
|
374
|
+
import aiohttp
|
|
375
|
+
|
|
376
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
|
|
377
|
+
if path in self.etags:
|
|
378
|
+
headers["If-None-Match"] = self.etags[path]
|
|
379
|
+
async with aiohttp.ClientSession() as session:
|
|
380
|
+
async with session.get(GITHUB_API + path, headers=headers) as response:
|
|
381
|
+
response_headers = dict(response.headers)
|
|
382
|
+
if response.status == 304:
|
|
383
|
+
return response.status, response_headers, None
|
|
384
|
+
if response.status >= 300:
|
|
385
|
+
body = redactor.redact(await response.text())[:300]
|
|
386
|
+
retry_after = response_headers.get("Retry-After") or response_headers.get("retry-after")
|
|
387
|
+
raise GitHubStatusError(response.status, f"github api GET {path} failed: {response.status} — {body}", retry_after=retry_after)
|
|
388
|
+
return response.status, response_headers, await response.json()
|
taskboy/router.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""model routing: pure function from (classification fields, override, config) to a routing decision.
|
|
2
|
+
|
|
3
|
+
rules live in config.yaml, never in code (MOD-002/006/007). first matching rule wins;
|
|
4
|
+
fields within a rule are ANDed, values within a field are ORed. no code path can pick
|
|
5
|
+
a model outside the configured catalog (MOD-009).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from taskboy.config import ConfigError, Role
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RoleRefusal(Exception):
|
|
14
|
+
"""a valid request selected permissions the caller's role does not grant."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RoutingDecision:
|
|
19
|
+
model_alias: str
|
|
20
|
+
model_id: str
|
|
21
|
+
fallback_chain: list[str] # aliases to try in order if the model is unavailable, preferred first
|
|
22
|
+
profile: str
|
|
23
|
+
max_budget_usd: float
|
|
24
|
+
max_turns: int
|
|
25
|
+
max_runtime_minutes: int
|
|
26
|
+
rationale: str # matched rule name + override note, recorded on the task (MOD-005)
|
|
27
|
+
effort: str | None = None # the classifier's pick, carried through to be persisted (issue #67); "auto" collapses to None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def route(task_type: str | None, complexity: str | None, model_override: str | None, raw_config: dict, *, role: Role | None = None, classifier_effort: str | None = None) -> RoutingDecision:
|
|
31
|
+
models = raw_config.get("models") or {}
|
|
32
|
+
routing = raw_config.get("routing") or {}
|
|
33
|
+
profiles = raw_config.get("profiles") or {}
|
|
34
|
+
if not models or not routing or not profiles:
|
|
35
|
+
raise ConfigError("config needs models, routing, and profiles sections to route tasks")
|
|
36
|
+
|
|
37
|
+
rule_name, tier, profile_name = _match_rule(task_type, complexity, routing)
|
|
38
|
+
rationale = f"rule: {rule_name}"
|
|
39
|
+
if model_override:
|
|
40
|
+
if role is not None and not role.model_override:
|
|
41
|
+
raise RoleRefusal(f"your {role.name} role does not allow model overrides")
|
|
42
|
+
if model_override not in models:
|
|
43
|
+
# an override may only pick from the catalog — never an unapproved model (MOD-008/009)
|
|
44
|
+
raise ConfigError(f"model override {model_override!r} is not in the configured model catalog")
|
|
45
|
+
tier = model_override
|
|
46
|
+
rationale = f"rule: {rule_name}, overridden to {model_override}"
|
|
47
|
+
|
|
48
|
+
if tier not in models:
|
|
49
|
+
raise ConfigError(f"routing selected tier {tier!r} which is not in the model catalog")
|
|
50
|
+
if profile_name not in profiles:
|
|
51
|
+
raise ConfigError(f"routing selected profile {profile_name!r} which is not configured")
|
|
52
|
+
if role is not None and profile_name not in role.allowed_profiles:
|
|
53
|
+
raise RoleRefusal(f"your {role.name} role does not allow the {profile_name} execution profile required by this request")
|
|
54
|
+
|
|
55
|
+
profile = profiles[profile_name]
|
|
56
|
+
max_budget_usd = float(profile["max_budget_usd"])
|
|
57
|
+
if role is not None:
|
|
58
|
+
if role.max_budget_usd is not None and role.max_budget_usd < max_budget_usd:
|
|
59
|
+
max_budget_usd = role.max_budget_usd
|
|
60
|
+
rationale += f", budget capped at ${max_budget_usd:g} by role {role.name}"
|
|
61
|
+
rationale += f", role: {role.name}"
|
|
62
|
+
return RoutingDecision(
|
|
63
|
+
model_alias=tier,
|
|
64
|
+
model_id=str(models[tier]["id"]),
|
|
65
|
+
fallback_chain=fallback_chain(tier, models),
|
|
66
|
+
profile=profile_name,
|
|
67
|
+
max_budget_usd=max_budget_usd,
|
|
68
|
+
max_turns=int(profile["max_turns"]),
|
|
69
|
+
max_runtime_minutes=int(profile["max_runtime_minutes"]),
|
|
70
|
+
rationale=rationale,
|
|
71
|
+
effort=classifier_effort if classifier_effort not in (None, "auto") else None,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def route_skill(model_override: str | None, raw_config: dict, *, role: Role | None = None, skill_tier: str | None = None, skill_profile: str | None = None) -> RoutingDecision:
|
|
76
|
+
models = raw_config.get("models") or {}
|
|
77
|
+
profiles = raw_config.get("profiles") or {}
|
|
78
|
+
if not models or not profiles:
|
|
79
|
+
raise ConfigError("config needs models and profiles sections to route skills")
|
|
80
|
+
skill_config = raw_config.get("skills") or {}
|
|
81
|
+
# a skill may declare its own model/profile in frontmatter (e.g. discovery runs on fable); the config
|
|
82
|
+
# default fills in otherwise. a user's explicit model_override still wins over both, below.
|
|
83
|
+
tier = str(skill_tier or skill_config.get("tier", "opus"))
|
|
84
|
+
profile_name = str(skill_profile or skill_config.get("profile", "standard"))
|
|
85
|
+
rationale = f"skill:{skill_tier}" if skill_tier else "skill"
|
|
86
|
+
if model_override:
|
|
87
|
+
if role is not None and not role.model_override:
|
|
88
|
+
raise RoleRefusal(f"your {role.name} role does not allow model overrides")
|
|
89
|
+
if model_override not in models:
|
|
90
|
+
raise ConfigError(f"model override {model_override!r} is not in the configured model catalog")
|
|
91
|
+
tier = model_override
|
|
92
|
+
rationale = f"skill, overridden to {model_override}"
|
|
93
|
+
if tier not in models:
|
|
94
|
+
raise ConfigError(f"skills selected tier {tier!r} which is not in the model catalog")
|
|
95
|
+
if profile_name not in profiles:
|
|
96
|
+
raise ConfigError(f"skills selected profile {profile_name!r} which is not configured")
|
|
97
|
+
if role is not None and profile_name not in role.allowed_profiles:
|
|
98
|
+
raise RoleRefusal(f"your {role.name} role does not allow the {profile_name} execution profile required by this request")
|
|
99
|
+
profile = profiles[profile_name]
|
|
100
|
+
max_budget_usd = float(profile["max_budget_usd"])
|
|
101
|
+
if role is not None:
|
|
102
|
+
if role.max_budget_usd is not None and role.max_budget_usd < max_budget_usd:
|
|
103
|
+
max_budget_usd = role.max_budget_usd
|
|
104
|
+
rationale += f", budget capped at ${max_budget_usd:g} by role {role.name}"
|
|
105
|
+
rationale += f", role: {role.name}"
|
|
106
|
+
return RoutingDecision(
|
|
107
|
+
model_alias=tier,
|
|
108
|
+
model_id=str(models[tier]["id"]),
|
|
109
|
+
fallback_chain=fallback_chain(tier, models),
|
|
110
|
+
profile=profile_name,
|
|
111
|
+
max_budget_usd=max_budget_usd,
|
|
112
|
+
max_turns=int(profile["max_turns"]),
|
|
113
|
+
max_runtime_minutes=int(profile["max_runtime_minutes"]),
|
|
114
|
+
rationale=rationale,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _match_rule(task_type: str | None, complexity: str | None, routing: dict) -> tuple[str, str, str]:
|
|
119
|
+
fields = {"task_type": task_type, "complexity": complexity}
|
|
120
|
+
for rule in routing.get("rules") or []:
|
|
121
|
+
match = rule.get("match") or {}
|
|
122
|
+
if all(fields.get(field) in allowed for field, allowed in match.items()):
|
|
123
|
+
return str(rule["name"]), str(rule["tier"]), str(rule["profile"])
|
|
124
|
+
default = routing.get("default")
|
|
125
|
+
if not default:
|
|
126
|
+
raise ConfigError("routing has no matching rule and no default")
|
|
127
|
+
return "default", str(default["tier"]), str(default["profile"])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def fallback_chain(tier: str, models: dict) -> list[str]:
|
|
131
|
+
"""preferred alias first, then the configured fallbacks in order; cycles stop rather than loop (MOD-009)."""
|
|
132
|
+
if tier not in models:
|
|
133
|
+
raise ConfigError(f"model alias {tier!r} is not in the model catalog")
|
|
134
|
+
chain = [tier]
|
|
135
|
+
current = tier
|
|
136
|
+
while True:
|
|
137
|
+
next_aliases = models[current].get("fallbacks") or []
|
|
138
|
+
if not next_aliases:
|
|
139
|
+
return chain
|
|
140
|
+
nxt = next_aliases[0]
|
|
141
|
+
if nxt not in models:
|
|
142
|
+
raise ConfigError(f"model {current!r} lists unknown fallback {nxt!r}")
|
|
143
|
+
if nxt in chain:
|
|
144
|
+
return chain # cycle — stop rather than loop
|
|
145
|
+
chain.append(nxt)
|
|
146
|
+
current = nxt
|