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/orchestrator.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""deterministic dispatcher: the db is the queue; this loop never talks to a model (the classifier and runners do)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
|
|
10
|
+
from taskboy.config import Config
|
|
11
|
+
from taskboy.models import BLOCKED, CANCELLED, COMPLETED, FAILED, QUEUED, RECEIVED, REFUSED, RUNNING, Task, utcnow
|
|
12
|
+
from taskboy.router import RoleRefusal
|
|
13
|
+
from taskboy.store import Store, TransitionRaced
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("taskboy.orchestrator")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def intake_paused(store: Store) -> bool:
|
|
19
|
+
return store.meta_get("intake_paused") == "1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def queue_at_capacity(store: Store, config: Config) -> bool:
|
|
23
|
+
return store.count_tasks(QUEUED) + store.count_tasks(RECEIVED) >= config.queue_max
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def accept_task(
|
|
27
|
+
store: Store,
|
|
28
|
+
config: Config,
|
|
29
|
+
notifier,
|
|
30
|
+
*,
|
|
31
|
+
team_id: str,
|
|
32
|
+
channel_id: str,
|
|
33
|
+
thread_ts: str,
|
|
34
|
+
message_ts: str,
|
|
35
|
+
user_id: str,
|
|
36
|
+
text: str,
|
|
37
|
+
parent_task_id: str | None = None,
|
|
38
|
+
model_override: str | None = None,
|
|
39
|
+
effort_override: str | None = None,
|
|
40
|
+
thread_context: str | None = None,
|
|
41
|
+
pre_classification: dict | None = None,
|
|
42
|
+
persona: str | None = None,
|
|
43
|
+
schedule_name: str | None = None,
|
|
44
|
+
debug_thread_ts: str | None = None,
|
|
45
|
+
debug_permalink: str | None = None,
|
|
46
|
+
) -> tuple[Task | None, str]:
|
|
47
|
+
"""shared intake for slack and the cli. returns (task, status): created | duplicate | paused | queue_full."""
|
|
48
|
+
if intake_paused(store):
|
|
49
|
+
return None, "paused"
|
|
50
|
+
existing = store.task_by_intake_key(team_id, channel_id, message_ts)
|
|
51
|
+
if existing is not None:
|
|
52
|
+
return existing, "duplicate"
|
|
53
|
+
if queue_at_capacity(store, config):
|
|
54
|
+
# refuse before creating the row so the dedup key survives for a retry once the queue drains;
|
|
55
|
+
# no task row exists, so record the refusal here to keep the audit trail (ORC-006)
|
|
56
|
+
store.add_error("intake", "queue_full", f"queue full — refused intake from {user_id}", context={"team_id": team_id, "channel_id": channel_id, "message_ts": message_ts, "text": text[:200]})
|
|
57
|
+
return None, "queue_full"
|
|
58
|
+
task, created = store.create_task(
|
|
59
|
+
slack_team_id=team_id,
|
|
60
|
+
slack_channel_id=channel_id,
|
|
61
|
+
slack_thread_ts=thread_ts,
|
|
62
|
+
slack_message_ts=message_ts,
|
|
63
|
+
slack_user_id=user_id,
|
|
64
|
+
request_text=text,
|
|
65
|
+
parent_task_id=parent_task_id,
|
|
66
|
+
model_override=model_override,
|
|
67
|
+
effort_override=effort_override,
|
|
68
|
+
thread_context=thread_context,
|
|
69
|
+
pre_classification=pre_classification,
|
|
70
|
+
persona=persona,
|
|
71
|
+
schedule_name=schedule_name,
|
|
72
|
+
debug_thread_ts=debug_thread_ts,
|
|
73
|
+
debug_permalink=debug_permalink,
|
|
74
|
+
)
|
|
75
|
+
if not created:
|
|
76
|
+
return task, "duplicate" # raced with a concurrent intake of the same message
|
|
77
|
+
ensure_debug = getattr(notifier, "ensure_debug", None)
|
|
78
|
+
if ensure_debug is not None:
|
|
79
|
+
try:
|
|
80
|
+
await ensure_debug(task)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
store.add_error("debug_feed", type(e).__name__, str(e), task_id=task.task_id, traceback=traceback.format_exc(), context={"operation": "ensure_debug"})
|
|
83
|
+
logger.warning("debug thread setup failed for %s", task.task_id, exc_info=True)
|
|
84
|
+
await notifier.ack(task)
|
|
85
|
+
return task, "created"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _notify_safe(fn, *args) -> None:
|
|
89
|
+
# a notifier outage must never take a task down with it
|
|
90
|
+
try:
|
|
91
|
+
await fn(*args)
|
|
92
|
+
except Exception:
|
|
93
|
+
logger.exception("notifier call failed")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def reopen_issue_and_cancel(store: Store, notifier, blocked: Task) -> bool:
|
|
97
|
+
"""cancel a blocked, issue-backed task; `store.transition` reopens its issue only once the cancel itself lands, so a raced/resumed task never leaves a live task plus a re-approvable issue (#76)."""
|
|
98
|
+
issue = store.issue_for_task(blocked.task_id)
|
|
99
|
+
if issue is None or issue["status"] != "in_progress":
|
|
100
|
+
return False
|
|
101
|
+
try:
|
|
102
|
+
cancelled = store.transition(blocked.task_id, BLOCKED, CANCELLED, "issue reopened: implementation could not finish", finished_at=utcnow())
|
|
103
|
+
except TransitionRaced:
|
|
104
|
+
return False
|
|
105
|
+
# transition()'s hook may have tracked the issue's PR instead of reopening it (#87) — refetch so the
|
|
106
|
+
# notification words itself off what actually happened, not the pre-transition snapshot
|
|
107
|
+
refreshed = store.get_issue(issue["id"]) or issue
|
|
108
|
+
await _notify_safe(notifier.issue_blocked, cancelled, refreshed)
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _expire_stale_blocked(store: Store, notifier, task: Task, max_days: int) -> bool:
|
|
113
|
+
"""fail (or, for an issue-backed task, cancel + reopen its issue) one task that outlived the stale-blocked window."""
|
|
114
|
+
reason = task.blocked_reason or "no reply"
|
|
115
|
+
if await reopen_issue_and_cancel(store, notifier, task):
|
|
116
|
+
store.add_event(task.task_id, "recovery", {"action": "expired_stale_blocked", "outcome": "issue_reopened", "reason": reason})
|
|
117
|
+
return True
|
|
118
|
+
try:
|
|
119
|
+
failed = store.transition(
|
|
120
|
+
task.task_id,
|
|
121
|
+
BLOCKED,
|
|
122
|
+
FAILED,
|
|
123
|
+
f"recovery: stale blocked task expired ({reason})",
|
|
124
|
+
error=f"blocked for over {max_days} days on {reason}",
|
|
125
|
+
finished_at=utcnow(),
|
|
126
|
+
)
|
|
127
|
+
except TransitionRaced:
|
|
128
|
+
return False
|
|
129
|
+
store.add_event(task.task_id, "recovery", {"action": "expired_stale_blocked", "outcome": "failed", "reason": reason})
|
|
130
|
+
await _notify_safe(notifier.failed, failed, f"This task expired after sitting blocked on {reason} for over {max_days} days.")
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# blocked tasks aren't terminal, so an abandoned one keeps its workspace forever (#104)
|
|
135
|
+
async def expire_stale_blocked_tasks(store: Store, notifier, retention: dict) -> dict:
|
|
136
|
+
"""post one reminder partway through the window, then fail (or cancel + reopen the issue) anything still BLOCKED once it elapses."""
|
|
137
|
+
max_days = retention.get("blocked_task_max_days", 10)
|
|
138
|
+
reminder_days = retention.get("blocked_task_reminder_days", 5)
|
|
139
|
+
now = datetime.now(timezone.utc)
|
|
140
|
+
reminded = 0
|
|
141
|
+
expired = 0
|
|
142
|
+
for task in store.tasks_in_state(BLOCKED):
|
|
143
|
+
updated_at = datetime.fromisoformat(task.updated_at)
|
|
144
|
+
age = now - updated_at
|
|
145
|
+
if age >= timedelta(days=max_days):
|
|
146
|
+
if await _expire_stale_blocked(store, notifier, task, max_days):
|
|
147
|
+
expired += 1
|
|
148
|
+
continue
|
|
149
|
+
if age < timedelta(days=reminder_days):
|
|
150
|
+
continue
|
|
151
|
+
# compare against updated_at, not "ever reminded" — a task that got answered and blocked again needs a fresh reminder (#104)
|
|
152
|
+
reminder_events = store.events_for_kinds(task.task_id, {"blocked_reminder"})
|
|
153
|
+
if reminder_events and datetime.fromisoformat(reminder_events[-1]["ts"]) >= updated_at:
|
|
154
|
+
continue
|
|
155
|
+
deadline = (updated_at + timedelta(days=max_days)).date().isoformat()
|
|
156
|
+
store.add_event(task.task_id, "blocked_reminder", {"expires_on": deadline})
|
|
157
|
+
await _notify_safe(notifier.progress, task, f"Still waiting on your reply here — this task expires on {deadline} if unanswered.")
|
|
158
|
+
reminded += 1
|
|
159
|
+
if reminded or expired:
|
|
160
|
+
logger.info("stale-blocked sweep: %s reminders sent, %s tasks expired", reminded, expired)
|
|
161
|
+
return {"reminded": reminded, "expired": expired}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Orchestrator:
|
|
165
|
+
def __init__(self, store: Store, config: Config, classify, run, notifier, memory_root: str | None = None):
|
|
166
|
+
self.store = store
|
|
167
|
+
self.config = config
|
|
168
|
+
self.classify = classify # async (Task) -> dict of task fields to set when queuing
|
|
169
|
+
self.run = run # async (Task) -> Outcome
|
|
170
|
+
self.notifier = notifier
|
|
171
|
+
self.memory_root = memory_root # when set, terminal tasks get a durable markdown summary (MEM-007)
|
|
172
|
+
self.wake = asyncio.Event()
|
|
173
|
+
self.running: dict[str, asyncio.Task] = {}
|
|
174
|
+
self.classifying: set[str] = set()
|
|
175
|
+
self.classify_handles: set[asyncio.Task] = set()
|
|
176
|
+
self.classify_semaphore = asyncio.Semaphore(4)
|
|
177
|
+
self.stopping = False
|
|
178
|
+
|
|
179
|
+
async def reconcile(self) -> None:
|
|
180
|
+
"""rehome tasks orphaned by a restart, before intake starts (REL-002/003)."""
|
|
181
|
+
released = self.store.release_stale_reservations()
|
|
182
|
+
if released:
|
|
183
|
+
logger.info("released %d stale issue reservation(s) at startup", released)
|
|
184
|
+
stranded = self.store.reopen_stranded_issues()
|
|
185
|
+
if stranded["reopened"]:
|
|
186
|
+
logger.info("reopened %d issue(s) stranded behind an already-terminal task", stranded["reopened"])
|
|
187
|
+
if stranded["tracked"]:
|
|
188
|
+
logger.info("resumed tracking %d issue(s) stranded behind an already-terminal task with an already-open PR", stranded["tracked"])
|
|
189
|
+
for task in self.store.tasks_in_state(RUNNING):
|
|
190
|
+
if task.attempt >= self.config.max_retries:
|
|
191
|
+
failed = self.store.transition(task.task_id, RUNNING, FAILED, "recovery: retry attempts exhausted", error="task was interrupted too many times", finished_at=utcnow())
|
|
192
|
+
self.store.add_event(task.task_id, "recovery", {"action": "failed", "attempt": task.attempt})
|
|
193
|
+
await _notify_safe(self.notifier.failed, failed, failed.error or "")
|
|
194
|
+
else:
|
|
195
|
+
requeued = self.store.transition(task.task_id, RUNNING, QUEUED, "recovery: orchestrator restarted", resume_session_id=task.session_id, attempt=task.attempt + 1)
|
|
196
|
+
self.store.add_event(task.task_id, "recovery", {"action": "requeued", "attempt": task.attempt + 1, "resume_session_id": task.session_id})
|
|
197
|
+
await _notify_safe(self.notifier.recovered, requeued)
|
|
198
|
+
|
|
199
|
+
async def dispatcher_loop(self) -> None:
|
|
200
|
+
while not self.stopping:
|
|
201
|
+
self.wake.clear()
|
|
202
|
+
self._cancel_tasks_marked_cancelled()
|
|
203
|
+
for task in self.store.tasks_in_state(RECEIVED):
|
|
204
|
+
if task.task_id not in self.classifying:
|
|
205
|
+
self.classifying.add(task.task_id)
|
|
206
|
+
handle = asyncio.create_task(self._classify_and_queue(task))
|
|
207
|
+
self.classify_handles.add(handle)
|
|
208
|
+
handle.add_done_callback(self.classify_handles.discard)
|
|
209
|
+
while not self.stopping and len(self.running) < self.config.max_concurrency:
|
|
210
|
+
queued = self.store.next_queued()
|
|
211
|
+
if queued is None:
|
|
212
|
+
break
|
|
213
|
+
try:
|
|
214
|
+
task = self.store.transition(queued.task_id, QUEUED, RUNNING, "dispatched", started_at=utcnow(), not_before=None)
|
|
215
|
+
except TransitionRaced:
|
|
216
|
+
continue
|
|
217
|
+
queued_at = _state_change_at(self.store, task.task_id, QUEUED) or task.created_at
|
|
218
|
+
self.store.add_event(task.task_id, "timing", {"stage": "queue_wait", "seconds": _seconds_between(queued_at, task.started_at)})
|
|
219
|
+
self.running[task.task_id] = asyncio.create_task(self._run_supervised(task))
|
|
220
|
+
try:
|
|
221
|
+
await asyncio.wait_for(self.wake.wait(), timeout=5)
|
|
222
|
+
except TimeoutError:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
async def shutdown(self) -> None:
|
|
226
|
+
"""stop dispatching, interrupt running tasks so they requeue for resume, and let the loop exit."""
|
|
227
|
+
self.stopping = True
|
|
228
|
+
self.wake.set()
|
|
229
|
+
handles = list(self.running.values()) + list(self.classify_handles)
|
|
230
|
+
for handle in handles:
|
|
231
|
+
handle.cancel()
|
|
232
|
+
await asyncio.gather(*handles, return_exceptions=True)
|
|
233
|
+
|
|
234
|
+
def _cancel_tasks_marked_cancelled(self) -> None:
|
|
235
|
+
# a cli cancel moves the row to cancelled; here we stop the in-flight coroutine cooperatively
|
|
236
|
+
for task_id, handle in list(self.running.items()):
|
|
237
|
+
current = self.store.get_task(task_id)
|
|
238
|
+
if current and current.state == CANCELLED:
|
|
239
|
+
handle.cancel()
|
|
240
|
+
|
|
241
|
+
async def _classify_and_queue(self, task: Task) -> None:
|
|
242
|
+
async with self.classify_semaphore:
|
|
243
|
+
classification_started = time.monotonic()
|
|
244
|
+
try:
|
|
245
|
+
fields = await self.classify(task)
|
|
246
|
+
except asyncio.CancelledError:
|
|
247
|
+
raise # shutdown mid-classify: row stays received, picked up after restart
|
|
248
|
+
except RoleRefusal as e:
|
|
249
|
+
self.store.add_error("classifier", type(e).__name__, str(e), task_id=task.task_id)
|
|
250
|
+
try:
|
|
251
|
+
failed = self.store.transition(task.task_id, RECEIVED, FAILED, "role refused routing", error=str(e), finished_at=utcnow())
|
|
252
|
+
await _notify_safe(self.notifier.refused, failed, str(e))
|
|
253
|
+
except TransitionRaced:
|
|
254
|
+
pass
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.exception("classification failed for %s", task.task_id)
|
|
257
|
+
self.store.add_error("classifier", type(e).__name__, str(e), task_id=task.task_id, traceback=traceback.format_exc())
|
|
258
|
+
try:
|
|
259
|
+
failed = self.store.transition(task.task_id, RECEIVED, FAILED, "classification failed", error=str(e), finished_at=utcnow())
|
|
260
|
+
await _notify_safe(self.notifier.failed, failed, str(e))
|
|
261
|
+
except TransitionRaced:
|
|
262
|
+
pass
|
|
263
|
+
else:
|
|
264
|
+
try:
|
|
265
|
+
if fields.get("task_type") == "unsupported":
|
|
266
|
+
# clear response, no execution (SLK-010); its own terminal state keeps it out of
|
|
267
|
+
# failure listings/metrics — it was refused on purpose, not a failure (issue #16)
|
|
268
|
+
try:
|
|
269
|
+
refused = self.store.transition(task.task_id, RECEIVED, REFUSED, "unsupported request", error="unsupported request", finished_at=utcnow(), **{k: v for k, v in fields.items() if k in ("classification_json", "task_type", "complexity", "routing_rationale")})
|
|
270
|
+
await _notify_safe(self.notifier.refused, refused, "this doesn't look like an engineering task I can take on — try asking for an investigation, a fix, a PR review, or a Jira operation")
|
|
271
|
+
except sqlite3.IntegrityError as e:
|
|
272
|
+
# stale schema whose tasks.state CHECK predates REFUSED (issue #58); record it and fall back to FAILED
|
|
273
|
+
logger.exception("refusal transition failed for %s", task.task_id)
|
|
274
|
+
self.store.add_error("orchestrator", type(e).__name__, str(e), task_id=task.task_id, traceback=traceback.format_exc(), context={"stage": "refuse"})
|
|
275
|
+
try:
|
|
276
|
+
failed = self.store.transition(task.task_id, RECEIVED, FAILED, "refusal transition failed", error=f"refusal transition failed: {e}", finished_at=utcnow())
|
|
277
|
+
await _notify_safe(self.notifier.failed, failed, str(e))
|
|
278
|
+
except (TransitionRaced, sqlite3.IntegrityError):
|
|
279
|
+
pass
|
|
280
|
+
else:
|
|
281
|
+
self.store.transition(task.task_id, RECEIVED, QUEUED, "classified", **fields)
|
|
282
|
+
except TransitionRaced:
|
|
283
|
+
pass # cancelled while classifying
|
|
284
|
+
finally:
|
|
285
|
+
self.store.add_event(task.task_id, "timing", {"stage": "classification", "seconds": time.monotonic() - classification_started})
|
|
286
|
+
self.classifying.discard(task.task_id)
|
|
287
|
+
self.wake.set()
|
|
288
|
+
|
|
289
|
+
async def _run_supervised(self, task: Task) -> None:
|
|
290
|
+
"""one task's lifetime; nothing that happens in here may take down the loop or sibling tasks (REL-005)."""
|
|
291
|
+
await _notify_safe(self.notifier.started, task)
|
|
292
|
+
# snapshot grants at run start; a grant that lands mid-run makes this differ (see the BLOCKED branch) (§8.4)
|
|
293
|
+
grants_before = self.store.granted_permissions_for(task.task_id)
|
|
294
|
+
try:
|
|
295
|
+
outcome = await self.run(task)
|
|
296
|
+
except asyncio.CancelledError:
|
|
297
|
+
if self.stopping:
|
|
298
|
+
# shutdown: requeue for resume after restart; a user cancel already moved the row to cancelled.
|
|
299
|
+
# re-read the row first: the in-memory `task` snapshot predates the run and never observes the
|
|
300
|
+
# session_id the runner persists mid-run (issue #92) — prefer the persisted value when present.
|
|
301
|
+
try:
|
|
302
|
+
current = self.store.get_task(task.task_id)
|
|
303
|
+
sid = (current.session_id if current else None) or task.session_id
|
|
304
|
+
self.store.transition(task.task_id, RUNNING, QUEUED, "requeued: orchestrator shutting down", resume_session_id=sid, attempt=task.attempt + 1)
|
|
305
|
+
except TransitionRaced:
|
|
306
|
+
pass
|
|
307
|
+
# a user cancel needs nothing here — it already moved the row to cancelled, which released the batch
|
|
308
|
+
self._release(task.task_id)
|
|
309
|
+
raise
|
|
310
|
+
except Exception as e:
|
|
311
|
+
logger.exception("runner crashed for %s", task.task_id)
|
|
312
|
+
self.store.add_error("orchestrator", type(e).__name__, str(e), task_id=task.task_id, traceback=traceback.format_exc(), context={"stage": "run"})
|
|
313
|
+
try:
|
|
314
|
+
failed = self.store.transition(task.task_id, RUNNING, FAILED, "runner crashed", error=str(e), finished_at=utcnow())
|
|
315
|
+
self._record_terminal_timing(failed)
|
|
316
|
+
await _notify_safe(self.notifier.failed, failed, str(e))
|
|
317
|
+
except TransitionRaced:
|
|
318
|
+
pass
|
|
319
|
+
self._release(task.task_id)
|
|
320
|
+
return
|
|
321
|
+
try:
|
|
322
|
+
if outcome.state == COMPLETED:
|
|
323
|
+
done = self.store.transition(task.task_id, RUNNING, COMPLETED, "runner finished", result_summary=outcome.result_summary, reply=outcome.reply, session_id=outcome.session_id, cost_usd=outcome.cost_usd, num_turns=outcome.num_turns, finished_at=utcnow())
|
|
324
|
+
self._record_terminal_timing(done)
|
|
325
|
+
self._write_memory(done)
|
|
326
|
+
await _notify_safe(self.notifier.completed, done)
|
|
327
|
+
elif outcome.state == BLOCKED:
|
|
328
|
+
blocked = self.store.transition(task.task_id, RUNNING, BLOCKED, "runner blocked", blocked_reason=outcome.blocked_reason, session_id=outcome.session_id, cost_usd=outcome.cost_usd, num_turns=outcome.num_turns)
|
|
329
|
+
self._record_terminal_timing(blocked)
|
|
330
|
+
self._write_memory(blocked)
|
|
331
|
+
# a grant can land while the session is still RUNNING; decide_permission only resumes a BLOCKED
|
|
332
|
+
# task, so that grant would strand this one here. compare against the run-start snapshot rather than
|
|
333
|
+
# timestamps (utcnow is second-resolution): a new grant this run makes the set differ, so resume it (§8.4).
|
|
334
|
+
if self.store.granted_permissions_for(task.task_id) != grants_before:
|
|
335
|
+
try:
|
|
336
|
+
self.store.transition(task.task_id, BLOCKED, QUEUED, "resumed: permission granted during run", resume_session_id=blocked.session_id)
|
|
337
|
+
self.store.add_event(task.task_id, "permission_decision", {"actor": "orchestrator", "decision": "resume", "note": "grant landed while session was running"})
|
|
338
|
+
except TransitionRaced:
|
|
339
|
+
pass
|
|
340
|
+
elif self.store.has_pending_permission_request(task.task_id):
|
|
341
|
+
# leave it BLOCKED: decide_permission's resume path needs this exact task_id still BLOCKED, and
|
|
342
|
+
# grants are keyed to it (§8.4) — reopening the issue would spawn a task with no grants and re-block
|
|
343
|
+
await self._notify_blocked(blocked)
|
|
344
|
+
elif not await reopen_issue_and_cancel(self.store, self.notifier, blocked):
|
|
345
|
+
# not issue-backed, or the issue already left a reopenable status (raced with a delete/refine)
|
|
346
|
+
await self._notify_blocked(blocked)
|
|
347
|
+
elif outcome.retryable and task.attempt < self.config.max_retries:
|
|
348
|
+
# transient claude usage/session limit: come back after the recorded reset instead of failing outright
|
|
349
|
+
requeued = self.store.transition(task.task_id, RUNNING, QUEUED, "requeued: session hit a usage limit", resume_session_id=outcome.session_id, attempt=task.attempt + 1, not_before=outcome.retry_not_before)
|
|
350
|
+
self.store.add_event(task.task_id, "recovery", {"action": "requeued_rate_limit", "attempt": task.attempt + 1, "not_before": outcome.retry_not_before})
|
|
351
|
+
await _notify_safe(self.notifier.recovered, requeued)
|
|
352
|
+
else:
|
|
353
|
+
failed = self.store.transition(task.task_id, RUNNING, FAILED, "runner reported failure", error=outcome.error, session_id=outcome.session_id, cost_usd=outcome.cost_usd, num_turns=outcome.num_turns, finished_at=utcnow())
|
|
354
|
+
self.store.add_error("runner", "session_failure", outcome.error, task_id=task.task_id)
|
|
355
|
+
self._record_terminal_timing(failed)
|
|
356
|
+
self._write_memory(failed)
|
|
357
|
+
await _notify_safe(self.notifier.failed, failed, outcome.error)
|
|
358
|
+
except TransitionRaced:
|
|
359
|
+
pass # cancelled out from under us; the cancel transition already recorded it
|
|
360
|
+
finally:
|
|
361
|
+
self._release(task.task_id)
|
|
362
|
+
|
|
363
|
+
async def _notify_blocked(self, blocked: Task) -> None:
|
|
364
|
+
pending = self.store.pending_questions_for(blocked.task_id)
|
|
365
|
+
ask = getattr(self.notifier, "questions", None)
|
|
366
|
+
if pending is not None and ask is not None:
|
|
367
|
+
await _notify_safe(ask, blocked, pending["questions"])
|
|
368
|
+
else:
|
|
369
|
+
await _notify_safe(self.notifier.blocked, blocked)
|
|
370
|
+
|
|
371
|
+
def _release(self, task_id: str) -> None:
|
|
372
|
+
self.running.pop(task_id, None)
|
|
373
|
+
self.wake.set()
|
|
374
|
+
|
|
375
|
+
def _record_terminal_timing(self, task: Task) -> None:
|
|
376
|
+
end = task.finished_at or utcnow()
|
|
377
|
+
self.store.add_event(task.task_id, "timing", {"stage": "run", "seconds": _seconds_between(task.started_at, end)})
|
|
378
|
+
self.store.add_event(task.task_id, "timing", {"stage": "total", "seconds": _seconds_between(task.created_at, end)})
|
|
379
|
+
|
|
380
|
+
def _write_memory(self, task: Task) -> None:
|
|
381
|
+
if self.memory_root is None:
|
|
382
|
+
return
|
|
383
|
+
try:
|
|
384
|
+
from taskboy import memory
|
|
385
|
+
|
|
386
|
+
memory.write_summary(self.memory_root, task, self.store.artifacts_for(task.task_id))
|
|
387
|
+
except Exception:
|
|
388
|
+
logger.exception("memory summary write failed for %s", task.task_id)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _seconds_between(start: str | None, end: str | None) -> float:
|
|
392
|
+
if not start or not end:
|
|
393
|
+
return 0.0
|
|
394
|
+
try:
|
|
395
|
+
return max((datetime.fromisoformat(end) - datetime.fromisoformat(start)).total_seconds(), 0.0)
|
|
396
|
+
except ValueError:
|
|
397
|
+
return 0.0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _state_change_at(store: Store, task_id: str, state: str) -> str | None:
|
|
401
|
+
return store.last_event_ts(task_id, "state_change", "to", state)
|
taskboy/personality.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""load the agent's operator-editable personality at the point of use."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load(path: str | None) -> tuple[str, str] | None:
|
|
8
|
+
if not path:
|
|
9
|
+
return None
|
|
10
|
+
try:
|
|
11
|
+
content = Path(path).read_text()
|
|
12
|
+
except (OSError, UnicodeError):
|
|
13
|
+
return None
|
|
14
|
+
text = content.strip()
|
|
15
|
+
if not text:
|
|
16
|
+
return None
|
|
17
|
+
return text, hashlib.sha256(content.encode()).hexdigest()
|