ahub 3.0.0__tar.gz
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.
- ahub-3.0.0/LICENSE +21 -0
- ahub-3.0.0/PKG-INFO +147 -0
- ahub-3.0.0/README.md +120 -0
- ahub-3.0.0/ahub/__init__.py +3 -0
- ahub-3.0.0/ahub/__main__.py +3 -0
- ahub-3.0.0/ahub/accept.py +257 -0
- ahub-3.0.0/ahub/archive.py +129 -0
- ahub-3.0.0/ahub/claude/SKILL.md +53 -0
- ahub-3.0.0/ahub/cli.py +77 -0
- ahub-3.0.0/ahub/cliutil.py +56 -0
- ahub-3.0.0/ahub/commands/__init__.py +1 -0
- ahub-3.0.0/ahub/commands/bot.py +26 -0
- ahub-3.0.0/ahub/commands/comms.py +161 -0
- ahub-3.0.0/ahub/commands/config_.py +41 -0
- ahub-3.0.0/ahub/commands/doctor.py +36 -0
- ahub-3.0.0/ahub/commands/draft.py +65 -0
- ahub-3.0.0/ahub/commands/mcp_.py +19 -0
- ahub-3.0.0/ahub/commands/models.py +125 -0
- ahub-3.0.0/ahub/commands/observer.py +47 -0
- ahub-3.0.0/ahub/commands/projects.py +34 -0
- ahub-3.0.0/ahub/commands/service.py +328 -0
- ahub-3.0.0/ahub/commands/setup.py +507 -0
- ahub-3.0.0/ahub/commands/task.py +328 -0
- ahub-3.0.0/ahub/commands/top.py +17 -0
- ahub-3.0.0/ahub/commands/version.py +18 -0
- ahub-3.0.0/ahub/comms.py +127 -0
- ahub-3.0.0/ahub/config.py +502 -0
- ahub-3.0.0/ahub/doctor.py +388 -0
- ahub-3.0.0/ahub/drafts.py +211 -0
- ahub-3.0.0/ahub/engine.py +631 -0
- ahub-3.0.0/ahub/events.py +223 -0
- ahub-3.0.0/ahub/gates.py +192 -0
- ahub-3.0.0/ahub/i18n/__init__.py +94 -0
- ahub-3.0.0/ahub/i18n/en.py +667 -0
- ahub-3.0.0/ahub/i18n/ru.py +667 -0
- ahub-3.0.0/ahub/log.py +203 -0
- ahub-3.0.0/ahub/mcp.py +173 -0
- ahub-3.0.0/ahub/migrations/001_init.sql +193 -0
- ahub-3.0.0/ahub/migrations/002_task_request.sql +2 -0
- ahub-3.0.0/ahub/migrations/003_event_deliveries.sql +2 -0
- ahub-3.0.0/ahub/model.py +151 -0
- ahub-3.0.0/ahub/observer.py +352 -0
- ahub-3.0.0/ahub/paths.py +57 -0
- ahub-3.0.0/ahub/prepare.py +110 -0
- ahub-3.0.0/ahub/procs.py +143 -0
- ahub-3.0.0/ahub/prompts.py +150 -0
- ahub-3.0.0/ahub/providers/__init__.py +34 -0
- ahub-3.0.0/ahub/providers/agy.py +342 -0
- ahub-3.0.0/ahub/providers/base.py +246 -0
- ahub-3.0.0/ahub/providers/fake.py +114 -0
- ahub-3.0.0/ahub/providers/fake_agent.py +73 -0
- ahub-3.0.0/ahub/providers/opencode.py +337 -0
- ahub-3.0.0/ahub/providers/opencode_db.py +482 -0
- ahub-3.0.0/ahub/providers/runner.py +318 -0
- ahub-3.0.0/ahub/pulse.py +136 -0
- ahub-3.0.0/ahub/registry.py +193 -0
- ahub-3.0.0/ahub/review.py +170 -0
- ahub-3.0.0/ahub/service.py +349 -0
- ahub-3.0.0/ahub/store.py +447 -0
- ahub-3.0.0/ahub/tasks.py +272 -0
- ahub-3.0.0/ahub/tg/__init__.py +0 -0
- ahub-3.0.0/ahub/tg/core.py +175 -0
- ahub-3.0.0/ahub/tg/launcher.py +227 -0
- ahub-3.0.0/ahub/tg/proxy.py +76 -0
- ahub-3.0.0/ahub/tg/run.py +183 -0
- ahub-3.0.0/ahub/time.py +133 -0
- ahub-3.0.0/ahub/transitions.py +193 -0
- ahub-3.0.0/ahub/tui/__init__.py +0 -0
- ahub-3.0.0/ahub/tui/app.py +300 -0
- ahub-3.0.0/ahub/tui/data.py +155 -0
- ahub-3.0.0/ahub/views.py +167 -0
- ahub-3.0.0/ahub/worker.py +56 -0
- ahub-3.0.0/ahub/workspace.py +115 -0
- ahub-3.0.0/ahub.egg-info/PKG-INFO +147 -0
- ahub-3.0.0/ahub.egg-info/SOURCES.txt +117 -0
- ahub-3.0.0/ahub.egg-info/dependency_links.txt +1 -0
- ahub-3.0.0/ahub.egg-info/entry_points.txt +2 -0
- ahub-3.0.0/ahub.egg-info/requires.txt +13 -0
- ahub-3.0.0/ahub.egg-info/top_level.txt +1 -0
- ahub-3.0.0/pyproject.toml +42 -0
- ahub-3.0.0/setup.cfg +4 -0
- ahub-3.0.0/tests/test_accept.py +204 -0
- ahub-3.0.0/tests/test_agy_provider.py +251 -0
- ahub-3.0.0/tests/test_cli.py +53 -0
- ahub-3.0.0/tests/test_config.py +308 -0
- ahub-3.0.0/tests/test_doctor.py +291 -0
- ahub-3.0.0/tests/test_drafts.py +62 -0
- ahub-3.0.0/tests/test_engine_code.py +215 -0
- ahub-3.0.0/tests/test_engine_scout.py +179 -0
- ahub-3.0.0/tests/test_events.py +111 -0
- ahub-3.0.0/tests/test_handles.py +128 -0
- ahub-3.0.0/tests/test_history.py +47 -0
- ahub-3.0.0/tests/test_i18n.py +361 -0
- ahub-3.0.0/tests/test_log.py +88 -0
- ahub-3.0.0/tests/test_mcp.py +51 -0
- ahub-3.0.0/tests/test_no_cyrillic.py +100 -0
- ahub-3.0.0/tests/test_observer.py +228 -0
- ahub-3.0.0/tests/test_opencode_db.py +312 -0
- ahub-3.0.0/tests/test_opencode_paths.py +93 -0
- ahub-3.0.0/tests/test_opencode_provider.py +164 -0
- ahub-3.0.0/tests/test_packaging.py +60 -0
- ahub-3.0.0/tests/test_procs.py +110 -0
- ahub-3.0.0/tests/test_prompts.py +149 -0
- ahub-3.0.0/tests/test_pulse.py +151 -0
- ahub-3.0.0/tests/test_registry.py +95 -0
- ahub-3.0.0/tests/test_review.py +32 -0
- ahub-3.0.0/tests/test_runner.py +212 -0
- ahub-3.0.0/tests/test_service.py +282 -0
- ahub-3.0.0/tests/test_service_cmd.py +123 -0
- ahub-3.0.0/tests/test_setup.py +52 -0
- ahub-3.0.0/tests/test_setup_global.py +62 -0
- ahub-3.0.0/tests/test_setup_wizard.py +152 -0
- ahub-3.0.0/tests/test_store.py +145 -0
- ahub-3.0.0/tests/test_tasks.py +128 -0
- ahub-3.0.0/tests/test_tg.py +278 -0
- ahub-3.0.0/tests/test_time.py +273 -0
- ahub-3.0.0/tests/test_transitions.py +178 -0
- ahub-3.0.0/tests/test_tui.py +146 -0
- ahub-3.0.0/tests/test_views.py +39 -0
ahub-3.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Takeh1ko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ahub-3.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ahub
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Service through which Claude Code and a human hand tasks to cheap worker models and accept the results
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Takeh1ko/agent-hub
|
|
7
|
+
Project-URL: Issues, https://github.com/Takeh1ko/agent-hub/issues
|
|
8
|
+
Keywords: agents,orchestrator,llm,telegram
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: textual>=0.80
|
|
18
|
+
Requires-Dist: psutil>=5.9; sys_platform != "linux"
|
|
19
|
+
Provides-Extra: telegram
|
|
20
|
+
Requires-Dist: aiogram>=3.13; extra == "telegram"
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
24
|
+
Requires-Dist: psutil>=5.9; extra == "dev"
|
|
25
|
+
Requires-Dist: aiogram>=3.13; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
<p align="center"><img src="https://raw.githubusercontent.com/Takeh1ko/agent-hub/main/docs/assets/banner.png" alt="agent-hub" width="100%"></p>
|
|
29
|
+
|
|
30
|
+
<p align="center"><a href="https://github.com/Takeh1ko/agent-hub/actions/workflows/ci.yml"><img src="https://github.com/Takeh1ko/agent-hub/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://pypi.org/project/ahub/"><img src="https://img.shields.io/pypi/v/ahub" alt="PyPI"></a> <img src="https://img.shields.io/badge/python-3.11%E2%80%933.13-blue" alt="Python 3.11–3.13"> <img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></p>
|
|
31
|
+
|
|
32
|
+
<p align="center"><a href="https://github.com/Takeh1ko/agent-hub/blob/main/README.ru.md">Русская версия</a></p>
|
|
33
|
+
|
|
34
|
+
Let Claude Code hand work to cheap models — and only spend its own tokens on decisions.
|
|
35
|
+
|
|
36
|
+
agent-hub is a local service between an orchestrator (Claude Code, or any CLI agent) and worker models
|
|
37
|
+
(opencode: Muse Spark, free models; Gemini via Antigravity). Claude files a task, goes quiet, and is woken by
|
|
38
|
+
one line when there is something to decide: accept, send back with notes, or reject. Everything in between —
|
|
39
|
+
running the worker in its own copy of the repo, checking its work, review by other models, retries, budgets —
|
|
40
|
+
the hub does without Claude.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Why
|
|
44
|
+
|
|
45
|
+
- **Claude's context is the expensive part.** A delegated task costs Claude about 2 KB of text for the whole
|
|
46
|
+
cycle: one event line, a summary capped at 4 KB, a one-word decision. Reports, diffs and logs are there on
|
|
47
|
+
request, never pushed.
|
|
48
|
+
- **Cheap models are good enough when the work is checked.** A task is not "done" because the model says so:
|
|
49
|
+
the commit exists, the diff stays inside the allowed files, the acceptance tests pass under a project lock,
|
|
50
|
+
and one or more reviewer models (in fresh sessions) agree. Typical costs on Muse Spark: recon $0.004–0.06,
|
|
51
|
+
code with review $0.02–0.13.
|
|
52
|
+
- **Nothing gets lost.** Events are stored until acknowledged, so a restarted Claude session, a restarted hub or a
|
|
53
|
+
dead terminal does not drop a "done". Worker processes outlive service restarts; orphaned tasks are picked up
|
|
54
|
+
again; an observer watches the hub itself.
|
|
55
|
+
|
|
56
|
+
## What it does
|
|
57
|
+
|
|
58
|
+
| | |
|
|
59
|
+
|---|---|
|
|
60
|
+
| Task kinds | `scout` (report only), `code` (branch + gates + review), `routine` (light changes), `review` (of a branch/commit/range) |
|
|
61
|
+
| Isolation | a git worktree per task, secrets hidden from the copy, clean environment for the worker |
|
|
62
|
+
| Gates | commit present, diff ⊆ allowed paths, structured result, acceptance tests under a shared lock |
|
|
63
|
+
| Review | panel of reviewer models in new sessions; disputes count only with file, line and reason; N rounds |
|
|
64
|
+
| Merge | `ahub accept` merges `--no-ff`, re-runs acceptance, rolls back if red |
|
|
65
|
+
| Failures | network error → retry; silence → one nudge; quota/timeout/budget → "needs decision"; never a silent hang |
|
|
66
|
+
| Budgets | per task (including review); at 100 % the worker is asked to save and stop, extension is one command |
|
|
67
|
+
| Waking Claude | `ahub watch` for Claude Code's Monitor, `ahub wait`; stable codes `DONE` `DECISION` `ERROR` `OWNER` `ANSWER` `ALARM` |
|
|
68
|
+
| Pulse | 🟢 working · 🟡 waiting for a reason · 🔴 silent · ⚫ dead · ⚪ no data — from the provider, processes and locks |
|
|
69
|
+
| Observer | code checks every 5 min, a model review every 30 min, escalation to Claude, then to you |
|
|
70
|
+
| Human | `ahub top` terminal UI; optional Telegram bot that talks to Claude (and starts Claude if no session is live) |
|
|
71
|
+
| Other agents | the same handles over MCP (`ahub mcp`) |
|
|
72
|
+
| Languages | English and Russian (`AHUB_LANG`, `lang` in config, or the locale) |
|
|
73
|
+
|
|
74
|
+
## How it compares
|
|
75
|
+
|
|
76
|
+
Several projects connect Claude Code to other agents. Most are thin bridges (start a session, poll its status).
|
|
77
|
+
agent-hub is narrower in scope and deeper in the task lifecycle:
|
|
78
|
+
|
|
79
|
+
| | agent-hub | thin MCP bridges (opencode-mcp, agent-delegation-mcp, agy bridges) | fleet orchestrators (claw-orchestrator, Composio agent-orchestrator) |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| Focus | Claude decides, cheap models work | pass a prompt, return output | many agents in parallel, dashboards, PR loops |
|
|
82
|
+
| Gates and acceptance tests before "done" | yes | no | partly |
|
|
83
|
+
| Review by other models | yes | no | yes |
|
|
84
|
+
| Orchestrator token budget as a design rule | yes (hard size limits) | no | no |
|
|
85
|
+
| Events survive restarts (ack) | yes | no | partly |
|
|
86
|
+
| Observer of the hub itself | yes | no | no |
|
|
87
|
+
| Web dashboard | no (terminal UI + Telegram) | no | yes |
|
|
88
|
+
| Number of supported agents | opencode, agy | one | many |
|
|
89
|
+
|
|
90
|
+
If you want a dashboard for twenty parallel agents with PR automation, use a fleet orchestrator. If you want
|
|
91
|
+
Claude Code to stop burning its context on work a cheaper model can do — and to trust the result — this is it.
|
|
92
|
+
|
|
93
|
+
<details>
|
|
94
|
+
<summary>See it work: a real task on Muse Spark ($0.007)</summary>
|
|
95
|
+
|
|
96
|
+
<img src="https://raw.githubusercontent.com/Takeh1ko/agent-hub/main/docs/demo.gif" alt="demo">
|
|
97
|
+
</details>
|
|
98
|
+
|
|
99
|
+
## Install
|
|
100
|
+
|
|
101
|
+
Requires Python 3.11+, git, and at least one provider: [opencode](https://opencode.ai) (free models work) or
|
|
102
|
+
Google Antigravity CLI (`agy`).
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
pipx install ahub
|
|
106
|
+
ahub setup # language, project, providers, models, service, Claude skill, Telegram (optional)
|
|
107
|
+
ahub doctor # what is wrong and how to fix it
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`ahub setup --yes` takes all defaults without questions. Telegram is optional: `pipx install 'ahub[telegram]'`.
|
|
111
|
+
|
|
112
|
+
Platforms: Linux (systemd), macOS (launchd; tested in CI), Windows via WSL2 only.
|
|
113
|
+
|
|
114
|
+
## Use
|
|
115
|
+
|
|
116
|
+
In Claude Code (after `ahub setup` installs the skill):
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
ahub task new --kind code --title "add retry to the payment client" \
|
|
120
|
+
--spec-file spec.md --paths "app/payments/**,tests/**" --accept "tests/test_payments.py"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Claude keeps a Monitor on `ahub watch`; when a line like `DONE T12 …` arrives it runs `ahub status T12` and decides:
|
|
124
|
+
`ahub accept T12`, `ahub rework T12 --notes "…"`, or `ahub reject T12`.
|
|
125
|
+
|
|
126
|
+
You: `ahub top` to watch (press `c` for control mode, `?` for help), `ahub status`, `ahub history`.
|
|
127
|
+
Plain-language tasks: `ahub draft new "what you want, in your words"` → preview → `ahub draft start N`.
|
|
128
|
+
|
|
129
|
+
Everything else: `ahub --help`.
|
|
130
|
+
|
|
131
|
+
## Documentation
|
|
132
|
+
|
|
133
|
+
- [docs/ARCHITECTURE.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/ARCHITECTURE.md) — code map, start here
|
|
134
|
+
- [docs/architecture.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/architecture.md) — design
|
|
135
|
+
- [docs/contracts.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/contracts.md) — interfaces between the parts
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
141
|
+
.venv/bin/python -m pytest -q # ~2.5 min, no network, HOME is faked
|
|
142
|
+
AHUB_LIVE=1 .venv/bin/python -m pytest -m live # real providers, costs money
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
ahub-3.0.0/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<p align="center"><img src="https://raw.githubusercontent.com/Takeh1ko/agent-hub/main/docs/assets/banner.png" alt="agent-hub" width="100%"></p>
|
|
2
|
+
|
|
3
|
+
<p align="center"><a href="https://github.com/Takeh1ko/agent-hub/actions/workflows/ci.yml"><img src="https://github.com/Takeh1ko/agent-hub/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://pypi.org/project/ahub/"><img src="https://img.shields.io/pypi/v/ahub" alt="PyPI"></a> <img src="https://img.shields.io/badge/python-3.11%E2%80%933.13-blue" alt="Python 3.11–3.13"> <img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></p>
|
|
4
|
+
|
|
5
|
+
<p align="center"><a href="https://github.com/Takeh1ko/agent-hub/blob/main/README.ru.md">Русская версия</a></p>
|
|
6
|
+
|
|
7
|
+
Let Claude Code hand work to cheap models — and only spend its own tokens on decisions.
|
|
8
|
+
|
|
9
|
+
agent-hub is a local service between an orchestrator (Claude Code, or any CLI agent) and worker models
|
|
10
|
+
(opencode: Muse Spark, free models; Gemini via Antigravity). Claude files a task, goes quiet, and is woken by
|
|
11
|
+
one line when there is something to decide: accept, send back with notes, or reject. Everything in between —
|
|
12
|
+
running the worker in its own copy of the repo, checking its work, review by other models, retries, budgets —
|
|
13
|
+
the hub does without Claude.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
- **Claude's context is the expensive part.** A delegated task costs Claude about 2 KB of text for the whole
|
|
19
|
+
cycle: one event line, a summary capped at 4 KB, a one-word decision. Reports, diffs and logs are there on
|
|
20
|
+
request, never pushed.
|
|
21
|
+
- **Cheap models are good enough when the work is checked.** A task is not "done" because the model says so:
|
|
22
|
+
the commit exists, the diff stays inside the allowed files, the acceptance tests pass under a project lock,
|
|
23
|
+
and one or more reviewer models (in fresh sessions) agree. Typical costs on Muse Spark: recon $0.004–0.06,
|
|
24
|
+
code with review $0.02–0.13.
|
|
25
|
+
- **Nothing gets lost.** Events are stored until acknowledged, so a restarted Claude session, a restarted hub or a
|
|
26
|
+
dead terminal does not drop a "done". Worker processes outlive service restarts; orphaned tasks are picked up
|
|
27
|
+
again; an observer watches the hub itself.
|
|
28
|
+
|
|
29
|
+
## What it does
|
|
30
|
+
|
|
31
|
+
| | |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Task kinds | `scout` (report only), `code` (branch + gates + review), `routine` (light changes), `review` (of a branch/commit/range) |
|
|
34
|
+
| Isolation | a git worktree per task, secrets hidden from the copy, clean environment for the worker |
|
|
35
|
+
| Gates | commit present, diff ⊆ allowed paths, structured result, acceptance tests under a shared lock |
|
|
36
|
+
| Review | panel of reviewer models in new sessions; disputes count only with file, line and reason; N rounds |
|
|
37
|
+
| Merge | `ahub accept` merges `--no-ff`, re-runs acceptance, rolls back if red |
|
|
38
|
+
| Failures | network error → retry; silence → one nudge; quota/timeout/budget → "needs decision"; never a silent hang |
|
|
39
|
+
| Budgets | per task (including review); at 100 % the worker is asked to save and stop, extension is one command |
|
|
40
|
+
| Waking Claude | `ahub watch` for Claude Code's Monitor, `ahub wait`; stable codes `DONE` `DECISION` `ERROR` `OWNER` `ANSWER` `ALARM` |
|
|
41
|
+
| Pulse | 🟢 working · 🟡 waiting for a reason · 🔴 silent · ⚫ dead · ⚪ no data — from the provider, processes and locks |
|
|
42
|
+
| Observer | code checks every 5 min, a model review every 30 min, escalation to Claude, then to you |
|
|
43
|
+
| Human | `ahub top` terminal UI; optional Telegram bot that talks to Claude (and starts Claude if no session is live) |
|
|
44
|
+
| Other agents | the same handles over MCP (`ahub mcp`) |
|
|
45
|
+
| Languages | English and Russian (`AHUB_LANG`, `lang` in config, or the locale) |
|
|
46
|
+
|
|
47
|
+
## How it compares
|
|
48
|
+
|
|
49
|
+
Several projects connect Claude Code to other agents. Most are thin bridges (start a session, poll its status).
|
|
50
|
+
agent-hub is narrower in scope and deeper in the task lifecycle:
|
|
51
|
+
|
|
52
|
+
| | agent-hub | thin MCP bridges (opencode-mcp, agent-delegation-mcp, agy bridges) | fleet orchestrators (claw-orchestrator, Composio agent-orchestrator) |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| Focus | Claude decides, cheap models work | pass a prompt, return output | many agents in parallel, dashboards, PR loops |
|
|
55
|
+
| Gates and acceptance tests before "done" | yes | no | partly |
|
|
56
|
+
| Review by other models | yes | no | yes |
|
|
57
|
+
| Orchestrator token budget as a design rule | yes (hard size limits) | no | no |
|
|
58
|
+
| Events survive restarts (ack) | yes | no | partly |
|
|
59
|
+
| Observer of the hub itself | yes | no | no |
|
|
60
|
+
| Web dashboard | no (terminal UI + Telegram) | no | yes |
|
|
61
|
+
| Number of supported agents | opencode, agy | one | many |
|
|
62
|
+
|
|
63
|
+
If you want a dashboard for twenty parallel agents with PR automation, use a fleet orchestrator. If you want
|
|
64
|
+
Claude Code to stop burning its context on work a cheaper model can do — and to trust the result — this is it.
|
|
65
|
+
|
|
66
|
+
<details>
|
|
67
|
+
<summary>See it work: a real task on Muse Spark ($0.007)</summary>
|
|
68
|
+
|
|
69
|
+
<img src="https://raw.githubusercontent.com/Takeh1ko/agent-hub/main/docs/demo.gif" alt="demo">
|
|
70
|
+
</details>
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
Requires Python 3.11+, git, and at least one provider: [opencode](https://opencode.ai) (free models work) or
|
|
75
|
+
Google Antigravity CLI (`agy`).
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
pipx install ahub
|
|
79
|
+
ahub setup # language, project, providers, models, service, Claude skill, Telegram (optional)
|
|
80
|
+
ahub doctor # what is wrong and how to fix it
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`ahub setup --yes` takes all defaults without questions. Telegram is optional: `pipx install 'ahub[telegram]'`.
|
|
84
|
+
|
|
85
|
+
Platforms: Linux (systemd), macOS (launchd; tested in CI), Windows via WSL2 only.
|
|
86
|
+
|
|
87
|
+
## Use
|
|
88
|
+
|
|
89
|
+
In Claude Code (after `ahub setup` installs the skill):
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
ahub task new --kind code --title "add retry to the payment client" \
|
|
93
|
+
--spec-file spec.md --paths "app/payments/**,tests/**" --accept "tests/test_payments.py"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Claude keeps a Monitor on `ahub watch`; when a line like `DONE T12 …` arrives it runs `ahub status T12` and decides:
|
|
97
|
+
`ahub accept T12`, `ahub rework T12 --notes "…"`, or `ahub reject T12`.
|
|
98
|
+
|
|
99
|
+
You: `ahub top` to watch (press `c` for control mode, `?` for help), `ahub status`, `ahub history`.
|
|
100
|
+
Plain-language tasks: `ahub draft new "what you want, in your words"` → preview → `ahub draft start N`.
|
|
101
|
+
|
|
102
|
+
Everything else: `ahub --help`.
|
|
103
|
+
|
|
104
|
+
## Documentation
|
|
105
|
+
|
|
106
|
+
- [docs/ARCHITECTURE.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/ARCHITECTURE.md) — code map, start here
|
|
107
|
+
- [docs/architecture.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/architecture.md) — design
|
|
108
|
+
- [docs/contracts.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/contracts.md) — interfaces between the parts
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
114
|
+
.venv/bin/python -m pytest -q # ~2.5 min, no network, HOME is faked
|
|
115
|
+
AHUB_LIVE=1 .venv/bin/python -m pytest -m live # real providers, costs money
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Decisions on results (V15–V18, architecture §6.7–§6.8): accept/merge, rework, reject, orchestrator edit,
|
|
2
|
+
path extension, budget top-up, model change, resume with a new brief.
|
|
3
|
+
|
|
4
|
+
Merge: task → "accepting" (lease held by whoever accepts; a second "accept" is refused) → gates at the copy's
|
|
5
|
+
current HEAD (orchestrator edit — if HEAD ≠ worker result commit: legitimate, with an event) → in the project root
|
|
6
|
+
`git merge --no-ff` into the work branch → acceptance under the test resource → red — roll the merge back →
|
|
7
|
+
push per config → "accepted", task_cleanup hook, copy and branch removed, archived.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ahub import archive, events, gates, prepare, registry, tasks, transitions, workspace
|
|
15
|
+
from ahub import log as hublog
|
|
16
|
+
from ahub.config import ProjectConfig
|
|
17
|
+
from ahub.engine import owner_token
|
|
18
|
+
from ahub.i18n import t as _t
|
|
19
|
+
from ahub.model import CHANGES_FILES, Ev, Kind, State
|
|
20
|
+
from ahub.store import Store, Task
|
|
21
|
+
|
|
22
|
+
_log = hublog.get("accept")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DecisionError(RuntimeError):
|
|
26
|
+
"""Action impossible (one line for the orchestrator)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get(store: Store, task_id: int) -> Task:
|
|
30
|
+
t = store.get_task(task_id)
|
|
31
|
+
if t is None:
|
|
32
|
+
raise DecisionError(_t("trans.no_task", id=task_id))
|
|
33
|
+
return t
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _root_ready(project: ProjectConfig) -> None:
|
|
37
|
+
cur = workspace.git(project.root, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
38
|
+
if cur != project.work_branch:
|
|
39
|
+
raise DecisionError(_t("accept.root_branch", cur=cur, branch=project.work_branch))
|
|
40
|
+
dirty = [ln for ln in workspace.git(project.root, "status", "--porcelain", "--untracked-files=no").stdout
|
|
41
|
+
.splitlines() if ln.strip()]
|
|
42
|
+
if dirty:
|
|
43
|
+
raise DecisionError(_t("accept.root_dirty", files=", ".join(x[3:] for x in dirty[:5])))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def accept(store: Store, project: ProjectConfig, task_id: int, *, by: str = "orchestrator") -> str:
|
|
47
|
+
t = _get(store, task_id)
|
|
48
|
+
if t.kind not in CHANGES_FILES:
|
|
49
|
+
if t.state not in (State.DONE, State.NEEDS_DECISION):
|
|
50
|
+
raise DecisionError(_t("accept.can_accept", label=t.label, state=t.state.value))
|
|
51
|
+
transitions.move(store, t.id, State.ACCEPTED, reason=_t("accept.accepted"), by=by)
|
|
52
|
+
events.ack_task(store, t.id)
|
|
53
|
+
archive.write_task(store, project, t.id)
|
|
54
|
+
workspace.remove(project, t.id, delete_branch=True)
|
|
55
|
+
return _t("accept.accepted_msg", label=t.label)
|
|
56
|
+
if t.state not in (State.DONE, State.NEEDS_DECISION, State.ACCEPTING):
|
|
57
|
+
raise DecisionError(_t("accept.can_accept", label=t.label, state=t.state.value))
|
|
58
|
+
if not t.worktree or not Path(t.worktree).is_dir():
|
|
59
|
+
raise DecisionError(_t("accept.no_worktree", label=t.label, wt=t.worktree or "—"))
|
|
60
|
+
_root_ready(project)
|
|
61
|
+
owner = owner_token()
|
|
62
|
+
if t.state is not State.ACCEPTING:
|
|
63
|
+
transitions.move(store, t.id, State.ACCEPTING, reason=_t("accept.accepting"), by=by,
|
|
64
|
+
expect_from={State.DONE, State.NEEDS_DECISION})
|
|
65
|
+
if not transitions.acquire(store, t.id, owner, pid=None):
|
|
66
|
+
raise DecisionError(_t("accept.busy", label=t.label))
|
|
67
|
+
try:
|
|
68
|
+
return _merge(store, project, _get(store, t.id), owner, by)
|
|
69
|
+
except DecisionError as e:
|
|
70
|
+
_back(store, t.id, owner, str(e))
|
|
71
|
+
raise
|
|
72
|
+
except Exception as e:
|
|
73
|
+
_log.exception("accept T%d failed", t.id, extra={"task": t.id})
|
|
74
|
+
_back(store, t.id, owner, _t("accept.fail", err=f"{type(e).__name__}: {e}"))
|
|
75
|
+
raise DecisionError(_t("accept.fail", err=e)) from e
|
|
76
|
+
finally:
|
|
77
|
+
transitions.release(store, t.id, owner)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _back(store: Store, task_id: int, owner: str, reason: str) -> None:
|
|
81
|
+
t = store.get_task(task_id)
|
|
82
|
+
if t is not None and t.state is State.ACCEPTING:
|
|
83
|
+
transitions.move(store, task_id, State.NEEDS_DECISION, reason=reason[:500], by="accept", owner=owner)
|
|
84
|
+
events.ack_task(store, task_id) # the orchestrator saw the refusal in the command output
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _merge(store: Store, project: ProjectConfig, t: Task, owner: str, by: str) -> str:
|
|
88
|
+
res = archive.read_json(Path(t.worktree) / workspace.AHUB_DIR / "result.json")
|
|
89
|
+
head = workspace.head(t.worktree)
|
|
90
|
+
orch_edit = not res.get("commit") or not head.startswith(str(res.get("commit"))[:7])
|
|
91
|
+
if orch_edit and not t.limits.get("orch_edit"):
|
|
92
|
+
store.add_event(Ev.ORCH_EDIT, task_id=t.id, project=t.project,
|
|
93
|
+
payload={"head": head[:12], "worker_commit": str(res.get("commit", ""))[:12], "by": by})
|
|
94
|
+
g = gates.check(project, t, run_tests=False, orch_edit=orch_edit)
|
|
95
|
+
problems = g.fatal + [p for p in g.repairable if not (orch_edit and p.startswith("result.json"))]
|
|
96
|
+
if problems:
|
|
97
|
+
raise DecisionError(_t("accept.gates_head", problems="; ".join(problems)))
|
|
98
|
+
title = t.title.replace('"', "'")[:100]
|
|
99
|
+
r = workspace.git(project.root, "merge", "--no-ff", "-m", f"merge {t.label}: {title}", t.branch, check=False)
|
|
100
|
+
if r.returncode != 0:
|
|
101
|
+
conflicts = workspace.git(project.root, "diff", "--name-only", "--diff-filter=U", check=False).stdout.split()
|
|
102
|
+
workspace.git(project.root, "merge", "--abort", check=False)
|
|
103
|
+
raise DecisionError(_t("accept.conflict", info=", ".join(conflicts[:10]) or (r.stderr or r.stdout)[-300:]))
|
|
104
|
+
merged = workspace.git(project.root, "rev-parse", "HEAD").stdout.strip()
|
|
105
|
+
nodes = list(t.limits.get("accept") or [])
|
|
106
|
+
if t.kind is Kind.CODE and nodes:
|
|
107
|
+
ok, tail, cmd = gates.run_acceptance(project, project.root, nodes, task_label=t.label)
|
|
108
|
+
if not ok:
|
|
109
|
+
head_now = workspace.git(project.root, "rev-parse", "HEAD").stdout.strip()
|
|
110
|
+
if head_now != merged: # someone committed into the work branch meanwhile — leave foreign commits alone
|
|
111
|
+
raise DecisionError(_t("accept.root_moved", now=head_now[:10], merged=merged[:10]))
|
|
112
|
+
workspace.git(project.root, "reset", "--keep", "HEAD~1", check=False) # --keep leaves foreign dirt alone
|
|
113
|
+
raise DecisionError(_t("accept.red_rolled_back", cmd=cmd, tail=tail[-600:]))
|
|
114
|
+
note = ""
|
|
115
|
+
if project.push.strip():
|
|
116
|
+
parts = project.push.split()
|
|
117
|
+
pr = workspace.git(project.root, "push", *parts, check=False, timeout=300)
|
|
118
|
+
if pr.returncode != 0:
|
|
119
|
+
note = _t("accept.push_fail", err=(pr.stderr or pr.stdout).strip()[-200:])
|
|
120
|
+
_log.warning("push T%d: %s", t.id, note, extra={"task": t.id})
|
|
121
|
+
transitions.move(store, t.id, State.ACCEPTED, reason=_t("accept.merged_reason", branch=project.work_branch,
|
|
122
|
+
note=note), by=by, owner=owner,
|
|
123
|
+
fields={"accepted_sha": merged})
|
|
124
|
+
events.ack_task(store, t.id)
|
|
125
|
+
try:
|
|
126
|
+
prepare.run_hook(project, "task_cleanup", t, t.worktree)
|
|
127
|
+
except prepare.PrepareError as e:
|
|
128
|
+
_log.warning("hook task_cleanup T%d: %s", t.id, e, extra={"task": t.id})
|
|
129
|
+
archive.write_task(store, project, t.id)
|
|
130
|
+
workspace.remove(project, t.id, delete_branch=True)
|
|
131
|
+
return _t("accept.merged_msg", label=t.label, branch=project.work_branch, sha=merged[:10], note=note)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def reject(store: Store, project: ProjectConfig, task_id: int, *, reason: str = "", by: str = "orchestrator",
|
|
135
|
+
keep: bool = False) -> str:
|
|
136
|
+
t = _get(store, task_id)
|
|
137
|
+
try:
|
|
138
|
+
transitions.move(store, t.id, State.REJECTED, reason=reason or _t("accept.rejected_default"), by=by)
|
|
139
|
+
except (transitions.TransitionError, transitions.ConflictError) as e:
|
|
140
|
+
raise DecisionError(f"{e}{_t('accept.active_first')}") from e
|
|
141
|
+
events.ack_task(store, t.id)
|
|
142
|
+
archive.write_task(store, project, t.id)
|
|
143
|
+
if not keep:
|
|
144
|
+
workspace.remove(project, t.id, delete_branch=True)
|
|
145
|
+
return _t("accept.rejected_msg", label=t.label)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def rework(store: Store, task_id: int, notes: str, *, by: str = "orchestrator") -> str:
|
|
149
|
+
"""Send back for rework with notes: same executor session, new round."""
|
|
150
|
+
t = _get(store, task_id)
|
|
151
|
+
if t.state not in (State.DONE, State.NEEDS_DECISION, State.ERROR, State.STOPPED):
|
|
152
|
+
raise DecisionError(_t("accept.rework_state", label=t.label, state=t.state.value))
|
|
153
|
+
if not notes.strip():
|
|
154
|
+
raise DecisionError(_t("accept.need_notes"))
|
|
155
|
+
lim = dict(t.limits)
|
|
156
|
+
lim["rework_notes"] = notes.strip()
|
|
157
|
+
store.update_task(t.id, limits=lim)
|
|
158
|
+
transitions.move(store, t.id, State.QUEUED, reason=_t("accept.rework"), by=by, fields={"round": t.round + 1},
|
|
159
|
+
payload={"notes": notes[:500]})
|
|
160
|
+
events.ack_task(store, t.id)
|
|
161
|
+
return _t("accept.rework_msg", label=t.label, round=t.round + 1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def continue_task(store: Store, task_id: int, *, by: str = "orchestrator") -> str:
|
|
165
|
+
t = _get(store, task_id)
|
|
166
|
+
if t.state not in (State.STOPPED, State.ERROR, State.NEEDS_DECISION):
|
|
167
|
+
raise DecisionError(_t("accept.continue_state", label=t.label, state=t.state.value))
|
|
168
|
+
transitions.move(store, t.id, State.QUEUED, reason=_t("accept.continue"), by=by)
|
|
169
|
+
events.ack_task(store, t.id)
|
|
170
|
+
return _t("accept.continued_msg", label=t.label)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def edit(store: Store, project: ProjectConfig, task_id: int, *, spec: str | None = None,
|
|
174
|
+
title: str | None = None, by: str = "orchestrator") -> str:
|
|
175
|
+
"""New brief: on resume — a fresh executor session (different fingerprint)."""
|
|
176
|
+
t = _get(store, task_id)
|
|
177
|
+
if t.state in (State.ACCEPTED, State.REJECTED) or t.state in transitions.ACTIVE:
|
|
178
|
+
raise DecisionError(_t("accept.edit_state", label=t.label))
|
|
179
|
+
new = tasks.TaskSpec(project=t.project, kind=t.kind, title=title if title is not None else t.title,
|
|
180
|
+
spec=spec if spec is not None else t.spec, result_format=t.result_format,
|
|
181
|
+
paths=list(t.limits.get("paths") or []), accept=list(t.limits.get("accept") or []),
|
|
182
|
+
review_input=str(t.limits.get("input") or ""))
|
|
183
|
+
h = tasks.spec_hash(new)
|
|
184
|
+
if h == t.spec_hash:
|
|
185
|
+
return _t("accept.edit_same", label=t.label)
|
|
186
|
+
lim = dict(t.limits)
|
|
187
|
+
lim["fresh_session"] = True
|
|
188
|
+
store.update_task(t.id, title=new.title, spec=new.spec, spec_hash=h, limits=lim)
|
|
189
|
+
store.add_event(Ev.STATE, task_id=t.id, project=t.project, payload={"edit": _t("accept.edit_text"), "by": by})
|
|
190
|
+
return _t("accept.edit_msg", label=t.label)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def extend_paths(store: Store, project: ProjectConfig, task_id: int, paths: list[str], *,
|
|
194
|
+
by: str = "orchestrator") -> str:
|
|
195
|
+
t = _get(store, task_id)
|
|
196
|
+
bad = [p for p in paths if tasks._path_escapes(p) or not tasks._glob_allowed(p, project.allowed_paths)]
|
|
197
|
+
if bad:
|
|
198
|
+
raise DecisionError(_t("accept.paths_outside", items=", ".join(bad)))
|
|
199
|
+
cur = list(t.limits.get("paths") or [])
|
|
200
|
+
added = [p for p in paths if p not in cur]
|
|
201
|
+
if not added:
|
|
202
|
+
return _t("accept.paths_same", label=t.label)
|
|
203
|
+
lim = dict(t.limits)
|
|
204
|
+
lim["paths"] = cur + added
|
|
205
|
+
store.update_task(t.id, limits=lim)
|
|
206
|
+
store.add_event(Ev.PATHS_EXTENDED, task_id=t.id, project=t.project, payload={"added": added, "by": by})
|
|
207
|
+
return _t("accept.paths_added", label=t.label, items=", ".join(added))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _stopped_by_budget(store: Store, task_id: int) -> bool:
|
|
211
|
+
"""Task parked on budget: after a hard budget stop (budget_hard) it never left "needs decision"."""
|
|
212
|
+
stopped = False
|
|
213
|
+
for e in store.events(task_id=task_id):
|
|
214
|
+
if e.kind == Ev.BUDGET_HARD.value:
|
|
215
|
+
stopped = True
|
|
216
|
+
elif e.kind == Ev.STATE.value and (e.payload or {}).get("to") != State.NEEDS_DECISION.value:
|
|
217
|
+
stopped = False
|
|
218
|
+
return stopped
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def extend_budget(store: Store, task_id: int, *, add: float | None = None, set_to: float | None = None,
|
|
222
|
+
add_usd: float | None = None, by: str = "orchestrator") -> str:
|
|
223
|
+
"""Top up the budget in one move: raised + resumed (if the task was parked on budget).
|
|
224
|
+
|
|
225
|
+
add/set_to — Go counter (subscription); add_usd — real money (default 0 = no spending).
|
|
226
|
+
"""
|
|
227
|
+
t = _get(store, task_id)
|
|
228
|
+
new = set_to if set_to is not None else t.budget_go + (add or 0.0)
|
|
229
|
+
new_usd = t.budget_usd + (add_usd or 0.0)
|
|
230
|
+
if new <= t.budget_go and set_to is None and new_usd <= t.budget_usd:
|
|
231
|
+
raise DecisionError(_t("accept.budget_need"))
|
|
232
|
+
store.update_task(t.id, budget_go=float(new), budget_usd=float(new_usd))
|
|
233
|
+
store.add_event(Ev.BUDGET_EXTENDED, task_id=t.id, project=t.project,
|
|
234
|
+
payload={"from": t.budget_go, "to": new, "usd_from": t.budget_usd, "usd_to": new_usd, "by": by})
|
|
235
|
+
msg = _t("accept.budget_msg", label=t.label, old=f"{t.budget_go:g}", new=f"{new:g}") + (
|
|
236
|
+
_t("accept.budget_usd", old=f"{t.budget_usd:g}", new=f"{new_usd:g}")
|
|
237
|
+
if new_usd != t.budget_usd else "")
|
|
238
|
+
if t.state is State.NEEDS_DECISION and _stopped_by_budget(store, t.id):
|
|
239
|
+
transitions.move(store, t.id, State.QUEUED, reason=_t("accept.budget_long"), by=by)
|
|
240
|
+
events.ack_task(store, t.id)
|
|
241
|
+
msg += _t("accept.budget_resumed")
|
|
242
|
+
return msg
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def change_model(store: Store, project: ProjectConfig, task_id: int, alias: str, *, by: str = "orchestrator") -> str:
|
|
246
|
+
t = _get(store, task_id)
|
|
247
|
+
if t.state in transitions.ACTIVE:
|
|
248
|
+
raise DecisionError(_t("accept.model_active", label=t.label))
|
|
249
|
+
try:
|
|
250
|
+
registry.check(store, alias, project)
|
|
251
|
+
except registry.RegistryError as e:
|
|
252
|
+
raise DecisionError(str(e)) from e
|
|
253
|
+
lim = dict(t.limits)
|
|
254
|
+
lim["fresh_session"] = True # never resume another model's session
|
|
255
|
+
store.update_task(t.id, executor=alias, limits=lim)
|
|
256
|
+
store.add_event(Ev.MODEL_CHANGED, task_id=t.id, project=t.project, payload={"from": t.executor, "to": alias, "by": by})
|
|
257
|
+
return _t("accept.model_msg", label=t.label, old=t.executor, new=alias)
|