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/llm.py ADDED
@@ -0,0 +1,125 @@
1
+ """small structured model calls used outside agent sessions.
2
+
3
+ these go through the claude agent sdk (the claude code cli) so billing follows the
4
+ cli's own login — usage credits/subscription — rather than a metered ANTHROPIC_API_KEY.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import shutil
10
+ import tempfile
11
+ from typing import Any
12
+
13
+ from taskboy.redact import redactor
14
+
15
+ logger = logging.getLogger("taskboy.llm")
16
+
17
+ _DIAGNOSTIC_SNIPPET_LEN = 300
18
+
19
+ # sdk raises this after already yielding a usable result frame (issue #80)
20
+ _TOLERABLE_ERROR_TEXT = "Claude Code returned an error result: success"
21
+
22
+ _JSON_FALLBACK_PREAMBLE = "Respond with only a single minified JSON object (no prose, no markdown fences) matching this schema exactly:\n"
23
+
24
+
25
+ class _NoFrameObserved(Exception):
26
+ """the tolerated SDK error (#80) arrived before any usable frame was seen (#93)."""
27
+
28
+
29
+ async def structured_call(model_id: str, prompt: str, schema: dict[str, Any]) -> tuple[dict, dict | None]:
30
+ """one tool-free schema-shaped call; retries once on failure, then raises."""
31
+ use_output_format = True
32
+ last_exc: Exception | None = None
33
+ for attempt in (1, 2):
34
+ try:
35
+ return await _structured_call_once(model_id, prompt, schema, use_output_format=use_output_format)
36
+ except Exception as exc:
37
+ last_exc = exc
38
+ if isinstance(exc, _NoFrameObserved):
39
+ # retrying the identical call fails identically, so the retry drops output_format and parses text
40
+ use_output_format = False
41
+ logger.warning("structured_call attempt %s/2 failed (%s): %s", attempt, type(exc).__name__, redactor.redact(str(exc))[:_DIAGNOSTIC_SNIPPET_LEN])
42
+ assert last_exc is not None
43
+ raise last_exc
44
+
45
+
46
+ async def _structured_call_once(model_id: str, prompt: str, schema: dict[str, Any], *, use_output_format: bool) -> tuple[dict, dict | None]:
47
+ from claude_agent_sdk import ClaudeAgentOptions, SystemMessage, query
48
+
49
+ cwd = tempfile.mkdtemp(prefix="taskboy-llm-")
50
+ call_prompt = prompt if use_output_format else f"{prompt}\n\n{_JSON_FALLBACK_PREAMBLE}{json.dumps(schema)}"
51
+ try:
52
+ options = ClaudeAgentOptions(
53
+ model=model_id,
54
+ max_turns=3, # structured output consumes an extra turn; 1 fails with error_max_turns (phase 0 finding)
55
+ allowed_tools=[],
56
+ setting_sources=[],
57
+ cwd=cwd,
58
+ output_format={"type": "json_schema", "schema": schema} if use_output_format else None,
59
+ effort="low",
60
+ )
61
+ final = None
62
+ seen: list[str] = []
63
+ try:
64
+ async for message in query(prompt=call_prompt, options=options):
65
+ seen.append(type(message).__name__)
66
+ # trailing session_state_changed marker must not clobber the result frame (#80)
67
+ if isinstance(message, SystemMessage) and message.subtype == "session_state_changed":
68
+ continue
69
+ final = message
70
+ except Exception as exc:
71
+ if str(exc) != _TOLERABLE_ERROR_TEXT:
72
+ raise
73
+ if final is None:
74
+ # surface what was actually seen instead of the same undiagnosable message every time
75
+ diag = f"{exc} (no result frame observed before raise; frames seen: {seen or ['none']})"
76
+ raise _NoFrameObserved(diag) from exc
77
+ logger.warning("tolerating SDK 'error result: success' (%s)", _diagnose(final))
78
+ finally:
79
+ shutil.rmtree(cwd, ignore_errors=True) # per-call temp cwd must not accumulate on the host
80
+ candidate = getattr(final, "structured_output", None)
81
+ if candidate is None:
82
+ result = getattr(final, "result", None)
83
+ candidate = _extract_json(result) if isinstance(result, str) else result
84
+ if not isinstance(candidate, dict):
85
+ raise ValueError(f"model returned no structured output ({_diagnose(final)})")
86
+ return candidate, extract_usage(final)
87
+
88
+
89
+ def _diagnose(message: Any) -> str:
90
+ """attributes a 'no structured output' failure to the terminal message shape instead of a bare traceback."""
91
+ if message is None:
92
+ return "type=None subtype=None result=None"
93
+ msg_type = type(message).__name__
94
+ subtype = getattr(message, "subtype", None)
95
+ result = getattr(message, "result", None)
96
+ snippet = redactor.redact(str(result))[:_DIAGNOSTIC_SNIPPET_LEN] if result is not None else None
97
+ return f"type={msg_type} subtype={subtype!r} result={snippet!r}"
98
+
99
+
100
+ def _extract_json(text: str) -> dict | None:
101
+ try:
102
+ return json.loads(text)
103
+ except json.JSONDecodeError:
104
+ pass
105
+ start, end = text.find("{"), text.rfind("}")
106
+ if start != -1 and end > start:
107
+ try:
108
+ return json.loads(text[start : end + 1])
109
+ except json.JSONDecodeError:
110
+ return None
111
+ return None
112
+
113
+
114
+ def extract_usage(message) -> dict | None:
115
+ usage = getattr(message, "usage", None) or {}
116
+ cost = getattr(message, "total_cost_usd", None)
117
+ if not usage and cost is None:
118
+ return None
119
+ return {
120
+ "input_tokens": usage.get("input_tokens"),
121
+ "output_tokens": usage.get("output_tokens"),
122
+ "cache_read_tokens": usage.get("cache_read_input_tokens"),
123
+ "cache_write_tokens": usage.get("cache_creation_input_tokens"),
124
+ "cost_usd": cost,
125
+ }
taskboy/main.py ADDED
@@ -0,0 +1,269 @@
1
+ """service entrypoint: wire store + orchestrator (+ slack when configured), handle signals, run until stopped."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ import os
7
+ import signal
8
+ import sys
9
+ import traceback
10
+ from typing import Any
11
+
12
+ from taskboy import repocache, settings, workspace
13
+ from taskboy.classifier import Classifier, stub_classify
14
+ from taskboy.config import ConfigError, load_config, role_for
15
+ from taskboy.notify import StdoutNotifier
16
+ from taskboy.orchestrator import Orchestrator
17
+ from taskboy.runner import ClaudeRunner, EchoRunner
18
+ from taskboy.store import Store
19
+
20
+ logger = logging.getLogger("taskboy")
21
+
22
+ SLACK_CONNECT_TIMEOUT_SECONDS = 60
23
+
24
+
25
+ async def serve_slack(handler: Any, store: Store, notifier: Any, connect_timeout: float = SLACK_CONNECT_TIMEOUT_SECONDS) -> None:
26
+ """slack_sdk's connect() retries forever and never raises, so detect a dead connection via task.done()."""
27
+ task = asyncio.ensure_future(handler.connect_async())
28
+ try:
29
+ await asyncio.wait({task}, timeout=connect_timeout)
30
+ if not task.done():
31
+ raise RuntimeError(f"slack socket-mode client did not connect within {connect_timeout}s")
32
+ await task
33
+ except asyncio.CancelledError:
34
+ task.cancel()
35
+ with contextlib.suppress(asyncio.CancelledError):
36
+ await task
37
+ raise
38
+ except Exception as e:
39
+ logger.exception("slack socket-mode handler failed to start")
40
+ store.add_error("slack", type(e).__name__, str(e), traceback=traceback.format_exc(), context={"operation": "socket_mode_start"})
41
+ debug = getattr(notifier, "debug", None)
42
+ if debug is not None:
43
+ await debug.system_error("slack", f"socket-mode handler failed to start: {type(e).__name__}: {e}")
44
+ if not task.done():
45
+ await task # connect() retries forever internally -- a late connection shouldn't be cancelled
46
+
47
+
48
+ def should_start_review_poller(config, broker) -> bool:
49
+ review_requests = (config.raw.get("github") or {}).get("review_requests") or {}
50
+ if config.runner != "claude" or broker is None or not review_requests.get("enabled", False):
51
+ return False
52
+ if role_for(config.roles, "github") is None:
53
+ logger.warning("github review-request polling is enabled but user 'github' has no configured role — poller disabled")
54
+ return False
55
+ return True
56
+
57
+
58
+ async def amain() -> None:
59
+ config = load_config(settings.CONFIG_PATH)
60
+ store = Store(settings.DB_PATH)
61
+
62
+ secrets = None
63
+ if config.slack.enabled or config.runner == "claude" or config.dashboard.enabled:
64
+ from taskboy.secrets import load_secrets
65
+
66
+ secrets = load_secrets()
67
+ if secrets.claude_oauth_token and not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
68
+ os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = secrets.claude_oauth_token # the sdk's cli subprocesses read it from the process env
69
+
70
+ notifier: Any # duck-typed interface, see notify.py
71
+ handler = None
72
+ slack_client = None
73
+ if config.slack.enabled:
74
+ from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
75
+
76
+ from taskboy import slack
77
+
78
+ assert secrets is not None # loaded above whenever slack is enabled
79
+ if not (secrets.slack_bot_token and secrets.slack_app_token):
80
+ raise ConfigError("slack.team_id is set but SLACK_BOT_TOKEN / SLACK_APP_TOKEN are not in the environment")
81
+ app, notifier = await slack.build(store, config, secrets.slack_bot_token)
82
+ slack_client = app.client
83
+ handler = AsyncSocketModeHandler(app, secrets.slack_app_token)
84
+ logger.info("slack intake enabled (team=%s)", config.slack.team_id)
85
+ else:
86
+ notifier = StdoutNotifier()
87
+ logger.info("slack intake disabled (services/slack.yaml enabled: false or team_id not set) — use `taskboy inject`")
88
+
89
+ broker = None
90
+ reviewer_broker = None
91
+ if config.runner == "claude":
92
+ from taskboy.broker import CredentialBroker
93
+
94
+ if not config.service_enabled("github"):
95
+ logger.info("github disabled in config — sessions run without git push auth or PR tools")
96
+ elif secrets is not None and secrets.github_enabled:
97
+ broker = CredentialBroker(secrets.github_app_id, secrets.github_installation_id, secrets.github_app_private_key, settings.BROKER_SOCKET, settings.GIT_CRED_HELPER)
98
+ await broker.start()
99
+ try:
100
+ await broker.verify((config.raw.get("github") or {}).get("approved_repos") or [])
101
+ logger.info("credential broker started, github app credentials verified (socket=%s)", settings.BROKER_SOCKET)
102
+ except Exception as e:
103
+ logger.warning("github app verification FAILED — git/PR operations will fail until fixed: %s", e)
104
+ else:
105
+ logger.info("github credentials not configured — sessions run without git push auth")
106
+ if config.reviewer.enabled:
107
+ if not config.service_enabled("github"):
108
+ logger.warning("reviewer is enabled but the github service is disabled — reviewer GitHub review tasks are disabled")
109
+ elif secrets is not None and secrets.reviewer_github_enabled:
110
+ candidate = CredentialBroker(secrets.reviewer_github_app_id, secrets.reviewer_github_installation_id, secrets.reviewer_github_app_private_key, settings.REVIEWER_BROKER_SOCKET, settings.GIT_CRED_HELPER)
111
+ try:
112
+ await candidate.start()
113
+ await candidate.verify((config.raw.get("github") or {}).get("approved_repos") or [])
114
+ except Exception as e:
115
+ logger.warning("reviewer github app verification FAILED — reviewer GitHub review tasks are disabled: %s", e)
116
+ await candidate.stop()
117
+ else:
118
+ reviewer_broker = candidate
119
+ logger.info("reviewer credential broker started, github app credentials verified (socket=%s)", settings.REVIEWER_BROKER_SOCKET)
120
+ else:
121
+ logger.warning("reviewer is enabled but the reviewer's GitHub identity is not configured — reviewer GitHub review tasks are disabled")
122
+ classify = Classifier(store, config).classify
123
+ run = ClaudeRunner(store, config, settings.WORKSPACES_ROOT, settings.MEMORY_ROOT, progress=notifier.progress, broker=broker, secrets=secrets, slack_client=slack_client, debug=getattr(notifier, "debug", None), reviewer_broker=reviewer_broker).run
124
+ assert secrets is not None
125
+ logger.info(
126
+ "session integrations: github=%s jira=%s confluence=%s sentry=%s aws=%s slack=%s",
127
+ broker is not None,
128
+ config.service_enabled("jira") and secrets.jira_enabled,
129
+ config.service_enabled("confluence") and secrets.jira_enabled,
130
+ config.service_enabled("sentry") and bool(secrets.sentry_token),
131
+ config.service_enabled("aws"),
132
+ slack_client is not None,
133
+ )
134
+ else:
135
+ classify = stub_classify
136
+ run = EchoRunner().run
137
+ orchestrator = Orchestrator(store, config, classify=classify, run=run, notifier=notifier, memory_root=settings.MEMORY_ROOT)
138
+ dashboard_store = None
139
+ dashboard_server = None
140
+ dashboard_task = None
141
+ if config.dashboard.enabled:
142
+ import uvicorn
143
+
144
+ from taskboy.dashboard import create_app
145
+
146
+ dashboard_store = Store(settings.DB_PATH) # own connection; wal keeps it safe alongside the orchestrator's
147
+ dashboard_app = create_app(dashboard_store, config, notifier, secrets, orchestrator=orchestrator, ui_dist=settings.UI_DIST)
148
+
149
+ class EmbeddedServer(uvicorn.Server):
150
+ def install_signal_handlers(self) -> None:
151
+ pass # the orchestrator owns process signals
152
+
153
+ dashboard_server = EmbeddedServer(uvicorn.Config(dashboard_app, host=config.dashboard.bind, port=config.dashboard.port, lifespan="off", log_level="warning"))
154
+
155
+ async def serve_dashboard() -> None:
156
+ try:
157
+ await dashboard_server.serve()
158
+ except asyncio.CancelledError:
159
+ raise
160
+ except BaseException as e:
161
+ dashboard_store.add_error("dashboard", type(e).__name__, str(e), traceback=traceback.format_exc())
162
+ logger.exception("dashboard stopped after an error")
163
+
164
+ dashboard_task = asyncio.create_task(serve_dashboard())
165
+ logger.info("mission control dashboard listening on %s:%s", config.dashboard.bind, config.dashboard.port)
166
+ review_poller = None
167
+ if should_start_review_poller(config, broker):
168
+ from taskboy.review_requests import ReviewRequestPoller
169
+
170
+ review_poller = asyncio.ensure_future(ReviewRequestPoller(store, config, broker, notifier, reviewer_broker=reviewer_broker).run())
171
+ loop = asyncio.get_running_loop()
172
+ for sig in (signal.SIGTERM, signal.SIGINT):
173
+ loop.add_signal_handler(sig, lambda: asyncio.ensure_future(orchestrator.shutdown()))
174
+
175
+ audit_bucket = str((config.raw.get("audit") or {}).get("bucket") or "")
176
+
177
+ async def housekeeping_loop() -> None:
178
+ from taskboy import audit
179
+ from taskboy.issue_runs import fail_stalled_implementation_run, sync_in_review
180
+ from taskboy.orchestrator import expire_stale_blocked_tasks
181
+
182
+ async def _step(name: str, fn) -> None:
183
+ # runs fn (sync or async) in isolation so one failing step can't also skip sync_in_review for an hour (#87)
184
+ try:
185
+ result = fn()
186
+ if asyncio.iscoroutine(result):
187
+ await result
188
+ except Exception as e:
189
+ store.add_error(name, type(e).__name__, str(e), traceback=traceback.format_exc())
190
+ debug = getattr(notifier, "debug", None)
191
+ if debug is not None:
192
+ try:
193
+ await debug.system_error(name, str(e))
194
+ except Exception:
195
+ # a notifier outage must never take housekeeping_loop down with it (nothing awaits `housekeeper`)
196
+ logger.exception("debug notifier failed while reporting housekeeping step %s", name)
197
+ logger.exception("housekeeping step %s failed", name)
198
+
199
+ async def _notify_stalled() -> None:
200
+ stalled_task_id = fail_stalled_implementation_run(store)
201
+ if stalled_task_id is not None:
202
+ message = f"failed stalled implementation coordinator {stalled_task_id}"
203
+ logger.warning(message)
204
+ debug = getattr(notifier, "debug", None)
205
+ if debug is not None:
206
+ await debug.system_error("housekeeping", message)
207
+
208
+ while True:
209
+ await _step("workspace_sweep", lambda: workspace.sweep_once(store, settings.WORKSPACES_ROOT, settings.MEMORY_ROOT, config.raw.get("retention") or {}))
210
+ await _step("expire_stale_blocked_tasks", lambda: expire_stale_blocked_tasks(store, notifier, config.raw.get("retention") or {}))
211
+ if audit_bucket:
212
+ await _step("audit_ship", lambda: audit.ship_once(store, audit_bucket))
213
+ await _step("audit_ship_admin", lambda: audit.ship_admin_once(store, audit_bucket))
214
+ if broker is not None:
215
+ await _step("repocache_refresh", lambda: repocache.refresh_all(store, broker, settings.REPOS_ROOT, (config.raw.get("github") or {}).get("approved_repos") or []))
216
+ await _step("sync_in_review", lambda: sync_in_review(store, broker))
217
+ await _step("fail_stalled_implementation_run", _notify_stalled)
218
+ await asyncio.sleep(3600)
219
+
220
+ await orchestrator.reconcile()
221
+ housekeeper = asyncio.ensure_future(housekeeping_loop())
222
+ scheduler_task = None
223
+ if config.runner == "claude":
224
+ from taskboy.scheduler import scheduler_loop, seed_default_schedules
225
+
226
+ seed_default_schedules(store, self_repo=str((config.raw.get("github") or {}).get("self_repo") or ""), github_enabled=config.service_enabled("github"))
227
+ scheduler_task = asyncio.ensure_future(scheduler_loop(store, config, notifier))
228
+ slack_task = None
229
+ if handler is not None:
230
+ slack_task = asyncio.ensure_future(serve_slack(handler, store, notifier))
231
+ logger.info("taskboy started (environment=%s, db=%s, audit_shipping=%s)", settings.ENVIRONMENT, settings.DB_PATH, bool(audit_bucket))
232
+ await orchestrator.dispatcher_loop()
233
+ housekeeper.cancel()
234
+ if scheduler_task is not None:
235
+ scheduler_task.cancel()
236
+ if review_poller is not None:
237
+ review_poller.cancel()
238
+ if slack_task is not None:
239
+ slack_task.cancel()
240
+ if handler is not None:
241
+ await handler.close_async()
242
+ if broker is not None:
243
+ await broker.stop()
244
+ if reviewer_broker is not None:
245
+ await reviewer_broker.stop()
246
+ if dashboard_server is not None:
247
+ dashboard_server.should_exit = True
248
+ if dashboard_task is not None:
249
+ try:
250
+ await asyncio.wait_for(dashboard_task, timeout=10)
251
+ except TimeoutError:
252
+ dashboard_task.cancel()
253
+ if dashboard_store is not None:
254
+ dashboard_store.close()
255
+ logger.info("taskboy stopped")
256
+ store.close()
257
+
258
+
259
+ def main() -> None:
260
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
261
+ try:
262
+ asyncio.run(amain())
263
+ except ConfigError as e:
264
+ print(f"invalid config: {e}", file=sys.stderr)
265
+ sys.exit(64) # matches systemd RestartPreventExitStatus=64: don't crash-loop on bad config
266
+
267
+
268
+ if __name__ == "__main__":
269
+ main()
taskboy/memory.py ADDED
@@ -0,0 +1,59 @@
1
+ """per-task markdown summaries (MEM-007) and thread-scoped recall for follow-ups (MEM-009/010).
2
+
3
+ the structured record is the store itself; these files are the human-readable layer.
4
+ scoping is structural: a follow-up only ever loads its immediate parent's summary — never
5
+ cross-thread or cross-repo context.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from taskboy.models import Task
11
+ from taskboy.redact import redactor
12
+ from taskboy.store import Store
13
+
14
+ RESULT_MAX_CHARS = 4000
15
+ RESULT_TRUNCATION_MARKER = "\n…(result truncated at 4000 chars)"
16
+
17
+
18
+ def write_summary(memory_root: str, task: Task, artifacts: list[dict]) -> Path:
19
+ """written on every terminal transition; the compact durable record of what happened (MEM-006)."""
20
+ lines = [
21
+ f"# task {task.task_id}",
22
+ "",
23
+ f"- requested by: {task.slack_user_id} in {task.slack_channel_id} (thread {task.slack_thread_ts})",
24
+ f"- state: {task.state}",
25
+ f"- model: {task.model_alias or 'n/a'} ({task.routing_rationale or 'unrouted'})",
26
+ f"- created: {task.created_at} finished: {task.finished_at or 'n/a'}",
27
+ f"- cost: ${task.cost_usd:.4f} over {task.num_turns} turns",
28
+ "",
29
+ ]
30
+ if artifacts:
31
+ lines.append("## artifacts")
32
+ for a in artifacts:
33
+ lines.append(f"- {a['kind']}: {a['external_id']}" + (f" ({a['url']})" if a.get("url") else ""))
34
+ lines.append("")
35
+ if task.result_summary:
36
+ result = task.result_summary
37
+ if len(result) > RESULT_MAX_CHARS:
38
+ result = result[: RESULT_MAX_CHARS - len(RESULT_TRUNCATION_MARKER)] + RESULT_TRUNCATION_MARKER
39
+ lines += ["## result", result, ""]
40
+ if task.error:
41
+ lines += ["## error", task.error, ""]
42
+ if task.blocked_reason:
43
+ lines += ["## blocked", task.blocked_reason, ""]
44
+ path = Path(memory_root) / "tasks" / f"{task.task_id}.md"
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ path.write_text(redactor.redact("\n".join(lines)))
47
+ return path
48
+
49
+
50
+ def read_summary(memory_root: str, task_id: str) -> str | None:
51
+ path = Path(memory_root) / "tasks" / f"{task_id}.md"
52
+ return path.read_text() if path.exists() else None
53
+
54
+
55
+ def parent_context(store: Store, memory_root: str, task: Task) -> str | None:
56
+ """the immediate parent's summary only — it already compresses earlier lineage, bounding size (MEM-009/010)."""
57
+ if not task.parent_task_id:
58
+ return None
59
+ return read_summary(memory_root, task.parent_task_id)
taskboy/models.py ADDED
@@ -0,0 +1,103 @@
1
+ """task states, allowed transitions, and the dataclasses passed between modules."""
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+
7
+ RECEIVED = "received"
8
+ QUEUED = "queued"
9
+ RUNNING = "running"
10
+ BLOCKED = "blocked"
11
+ COMPLETED = "completed"
12
+ FAILED = "failed"
13
+ CANCELLED = "cancelled"
14
+ REFUSED = "refused"
15
+
16
+ STATES = [RECEIVED, QUEUED, RUNNING, BLOCKED, COMPLETED, FAILED, CANCELLED, REFUSED]
17
+ TERMINAL_STATES = [COMPLETED, FAILED, CANCELLED, REFUSED]
18
+
19
+ # the sdk's reasoning-effort levels (issue #67); "auto" is a classifier-only value meaning "no opinion" and is
20
+ # never persisted or passed to the sdk — see taskboy.classifier and taskboy.runner.session_option_kwargs
21
+ EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
22
+
23
+ # every transition in the system; store.transition rejects anything else (ORC-007)
24
+ ALLOWED_TRANSITIONS: dict[str, list[str]] = {
25
+ # REFUSED only happens at classification time, so it's only reachable from RECEIVED (issue #16)
26
+ RECEIVED: [QUEUED, BLOCKED, FAILED, CANCELLED, REFUSED],
27
+ QUEUED: [RUNNING, FAILED, CANCELLED],
28
+ RUNNING: [COMPLETED, FAILED, BLOCKED, QUEUED, CANCELLED],
29
+ BLOCKED: [QUEUED, FAILED, CANCELLED],
30
+ }
31
+
32
+
33
+ def utcnow() -> str:
34
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
35
+
36
+
37
+ def new_task_id() -> str:
38
+ # date prefix keeps ids sortable and slack-readable
39
+ return "t" + datetime.now(timezone.utc).strftime("%Y%m%d") + "-" + uuid.uuid4().hex[:8]
40
+
41
+
42
+ @dataclass
43
+ class Task:
44
+ task_id: str
45
+ idempotency_key: str
46
+ state: str
47
+ attempt: int
48
+ resume_session_id: str | None
49
+ not_before: str | None
50
+ slack_team_id: str
51
+ slack_channel_id: str
52
+ slack_thread_ts: str
53
+ slack_message_ts: str
54
+ slack_user_id: str
55
+ request_text: str
56
+ thread_context: str | None
57
+ persona: str | None
58
+ schedule_name: str | None
59
+ parent_task_id: str | None
60
+ classification_json: str | None
61
+ task_type: str | None
62
+ complexity: str | None
63
+ risk: str | None
64
+ model_alias: str | None
65
+ model_id: str | None
66
+ profile: str | None
67
+ routing_rationale: str | None
68
+ model_override: str | None
69
+ effort_override: str | None
70
+ effort: str | None # resolved by routing: effort_override > classifier's pick > profile default (issue #67)
71
+ session_id: str | None
72
+ workspace_path: str | None
73
+ max_budget_usd: float | None
74
+ max_turns: int | None
75
+ max_runtime_minutes: int | None
76
+ cost_usd: float
77
+ num_turns: int
78
+ blocked_reason: str | None
79
+ error: str | None
80
+ result_summary: str | None
81
+ reply: str | None
82
+ debug_thread_ts: str | None
83
+ debug_permalink: str | None
84
+ created_at: str
85
+ started_at: str | None
86
+ finished_at: str | None
87
+ updated_at: str
88
+
89
+
90
+ @dataclass
91
+ class Outcome:
92
+ """what a runner reports back for a finished session."""
93
+
94
+ state: str # completed | failed | blocked
95
+ result_summary: str = ""
96
+ reply: str = ""
97
+ error: str = ""
98
+ blocked_reason: str = ""
99
+ session_id: str | None = None
100
+ cost_usd: float = 0.0
101
+ num_turns: int = 0
102
+ retryable: bool = False
103
+ retry_not_before: str | None = None
taskboy/mrkdwn.py ADDED
@@ -0,0 +1,19 @@
1
+ """small markdown-to-Slack-mrkdwn conversion for lifecycle posts."""
2
+
3
+ import re
4
+
5
+
6
+ def to_mrkdwn(text: str) -> str:
7
+ """markdown -> slack mrkdwn. code fences pass through untouched.
8
+
9
+ Inline-code spans are intentionally not shielded from bold and link conversion.
10
+ """
11
+ parts = re.split(r"(```.*?```)", text, flags=re.S)
12
+ for index in range(0, len(parts), 2):
13
+ part = parts[index]
14
+ part = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", part, flags=re.MULTILINE)
15
+ part = re.sub(r"\*\*(.+?)\*\*", r"*\1*", part)
16
+ part = re.sub(r"^(\s*)-\s+", r"\1• ", part, flags=re.MULTILINE)
17
+ part = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", r"<\2|\1>", part)
18
+ parts[index] = part
19
+ return "".join(parts)
taskboy/notify.py ADDED
@@ -0,0 +1,41 @@
1
+ """notifier interface (duck-typed) + stdout implementation, used before slack lands and by `inject --no-slack`.
2
+
3
+ any notifier must provide: ack, started, completed, failed, blocked, issue_blocked, recovered, refused, refuse_intake, answer.
4
+ """
5
+
6
+ from taskboy.models import Task
7
+
8
+
9
+ class StdoutNotifier:
10
+ async def ack(self, task: Task) -> None:
11
+ print(f"[{task.task_id}] accepted")
12
+
13
+ async def started(self, task: Task) -> None:
14
+ print(f"[{task.task_id}] running")
15
+
16
+ async def completed(self, task: Task) -> None:
17
+ print(f"[{task.task_id}] completed: {task.result_summary}")
18
+
19
+ async def failed(self, task: Task, error: str) -> None:
20
+ print(f"[{task.task_id}] failed: {error}")
21
+
22
+ async def blocked(self, task: Task) -> None:
23
+ print(f"[{task.task_id}] blocked: {task.blocked_reason}")
24
+
25
+ async def issue_blocked(self, task: Task, issue: dict) -> None:
26
+ print(f"[{task.task_id}] blocked: {task.blocked_reason} (reopened issue #{issue['id']})")
27
+
28
+ async def recovered(self, task: Task) -> None:
29
+ print(f"[{task.task_id}] requeued after restart")
30
+
31
+ async def refused(self, task: Task, reason: str) -> None:
32
+ print(f"[{task.task_id}] refused: {reason}")
33
+
34
+ async def refuse_intake(self, channel_id: str, thread_ts: str, reason: str) -> None:
35
+ print(f"[intake {channel_id}/{thread_ts}] refused: {reason}")
36
+
37
+ async def answer(self, channel_id: str, thread_ts: str, text: str) -> None:
38
+ print(f"[answer {channel_id}/{thread_ts}] {text}")
39
+
40
+ async def progress(self, task: Task, message: str) -> None:
41
+ print(f"[{task.task_id}] progress: {message}")