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.
Files changed (95) hide show
  1. taskboy/__init__.py +0 -0
  2. taskboy/adapters/__init__.py +0 -0
  3. taskboy/adapters/_util.py +30 -0
  4. taskboy/adapters/aws_read.py +107 -0
  5. taskboy/adapters/confluence.py +88 -0
  6. taskboy/adapters/github_api.py +564 -0
  7. taskboy/adapters/issues.py +435 -0
  8. taskboy/adapters/jira.py +263 -0
  9. taskboy/adapters/sentry.py +82 -0
  10. taskboy/adapters/slack_history.py +204 -0
  11. taskboy/assets.py +27 -0
  12. taskboy/audit.py +78 -0
  13. taskboy/broker.py +266 -0
  14. taskboy/classifier.py +219 -0
  15. taskboy/cli.py +161 -0
  16. taskboy/config.py +539 -0
  17. taskboy/dashboard/__init__.py +5 -0
  18. taskboy/dashboard/api.py +963 -0
  19. taskboy/dashboard/app.py +78 -0
  20. taskboy/dashboard/auth.py +117 -0
  21. taskboy/dashboard/editors.py +140 -0
  22. taskboy/dashboard/gitops.py +60 -0
  23. taskboy/dashboard/render.py +48 -0
  24. taskboy/debug_feed.py +170 -0
  25. taskboy/deploy/env.example +17 -0
  26. taskboy/deploy/git-cred-helper.py +36 -0
  27. taskboy/deploy/install.sh +101 -0
  28. taskboy/deploy/remote-update.sh +52 -0
  29. taskboy/deploy/taskboy-restart.path +10 -0
  30. taskboy/deploy/taskboy-restart.service +8 -0
  31. taskboy/deploy/taskboy.service +22 -0
  32. taskboy/hooks.py +208 -0
  33. taskboy/issue_runs.py +203 -0
  34. taskboy/llm.py +125 -0
  35. taskboy/main.py +269 -0
  36. taskboy/memory.py +59 -0
  37. taskboy/models.py +103 -0
  38. taskboy/mrkdwn.py +19 -0
  39. taskboy/notify.py +41 -0
  40. taskboy/orchestrator.py +401 -0
  41. taskboy/personality.py +17 -0
  42. taskboy/prompts.py +288 -0
  43. taskboy/quick.py +234 -0
  44. taskboy/redact.py +49 -0
  45. taskboy/repocache.py +117 -0
  46. taskboy/review_requests.py +388 -0
  47. taskboy/router.py +146 -0
  48. taskboy/runner.py +581 -0
  49. taskboy/scheduler.py +301 -0
  50. taskboy/secrets.py +83 -0
  51. taskboy/settings.py +19 -0
  52. taskboy/setup_checks.py +184 -0
  53. taskboy/setup_wizard.py +699 -0
  54. taskboy/skills.py +95 -0
  55. taskboy/slack.py +666 -0
  56. taskboy/slack_users.py +32 -0
  57. taskboy/started_messages.py +33 -0
  58. taskboy/store.py +1393 -0
  59. taskboy/task_actions.py +86 -0
  60. taskboy/templates/config.example.yaml +149 -0
  61. taskboy/templates/conventions.md +60 -0
  62. taskboy/templates/help.example.md +29 -0
  63. taskboy/templates/personality_agent.example.md +5 -0
  64. taskboy/templates/personality_reviewer.example.md +5 -0
  65. taskboy/templates/services/aws.yaml +7 -0
  66. taskboy/templates/services/confluence.yaml +4 -0
  67. taskboy/templates/services/github.yaml +14 -0
  68. taskboy/templates/services/jira.yaml +6 -0
  69. taskboy/templates/services/sentry.yaml +4 -0
  70. taskboy/templates/services/slack.yaml +9 -0
  71. taskboy/templates/skills/README.md +33 -0
  72. taskboy/templates/skills/discoverissues/SKILL.md +46 -0
  73. taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
  74. taskboy/templates/skills/jira2pr/SKILL.md +66 -0
  75. taskboy/templates/skills/monitor/SKILL.md +32 -0
  76. taskboy/templates/skills/monitornew/SKILL.md +27 -0
  77. taskboy/templates/skills/refineissue/SKILL.md +35 -0
  78. taskboy/templates/skills/release/SKILL.md +36 -0
  79. taskboy/templates/skills/review/SKILL.md +89 -0
  80. taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
  81. taskboy/templates/skills/reviews/SKILL.md +26 -0
  82. taskboy/templates/skills/slack2jira/SKILL.md +47 -0
  83. taskboy/templates/skills/slack2pr/SKILL.md +64 -0
  84. taskboy/templates/skills/spec2pr/SKILL.md +35 -0
  85. taskboy/templates/slack_app_manifest.yaml +48 -0
  86. taskboy/templates/task_started_messages.yaml +14 -0
  87. taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
  88. taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
  89. taskboy/ui_dist/index.html +15 -0
  90. taskboy/workspace.py +86 -0
  91. taskboy-0.1.1.dist-info/METADATA +120 -0
  92. taskboy-0.1.1.dist-info/RECORD +95 -0
  93. taskboy-0.1.1.dist-info/WHEEL +5 -0
  94. taskboy-0.1.1.dist-info/entry_points.txt +2 -0
  95. 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