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/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}")
|