agent-dispatch 0.11.0__tar.gz → 0.12.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.
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/.github/workflows/ci.yml +2 -2
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/.github/workflows/publish.yml +2 -2
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/AGENTS.md +18 -3
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/CHANGELOG.md +122 -1
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/PKG-INFO +14 -7
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/README.md +12 -5
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/pyproject.toml +6 -2
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/cache.py +33 -5
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/cli.py +205 -87
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/config.py +150 -5
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/jobs.py +45 -17
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/models.py +20 -5
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/runner.py +163 -20
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/server.py +230 -40
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_cache.py +82 -13
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_cli.py +126 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_config.py +146 -1
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_jobs.py +79 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_models.py +41 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_runner.py +233 -2
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_server.py +313 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/.gitignore +0 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/LICENSE +0 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/SECURITY.md +0 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/agents.example.yaml +0 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/assets/mascot.png +0 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/__init__.py +0 -0
- {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/conftest.py +0 -0
|
@@ -18,9 +18,9 @@ jobs:
|
|
|
18
18
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
19
19
|
|
|
20
20
|
steps:
|
|
21
|
-
- uses: actions/checkout@
|
|
21
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
22
22
|
|
|
23
|
-
- uses: actions/setup-python@
|
|
23
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
24
24
|
with:
|
|
25
25
|
python-version: ${{ matrix.python-version }}
|
|
26
26
|
|
|
@@ -15,9 +15,9 @@ jobs:
|
|
|
15
15
|
id-token: write # OIDC token for PyPI Trusted Publisher
|
|
16
16
|
contents: read # checkout the tagged source
|
|
17
17
|
steps:
|
|
18
|
-
- uses: actions/checkout@
|
|
18
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
19
19
|
|
|
20
|
-
- uses: actions/setup-python@
|
|
20
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
21
21
|
with:
|
|
22
22
|
python-version: "3.12"
|
|
23
23
|
|
|
@@ -28,10 +28,10 @@ pip install -e ".[dev]"
|
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
ruff check src/ tests/
|
|
31
|
-
python3 -m pytest tests/ -v #
|
|
31
|
+
python3 -m pytest tests/ -v # 561 tests, ~4s
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.which` + `subprocess.run`/`Popen`; server tests mock `_get_config` + `runner.dispatch`.
|
|
34
|
+
Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.which` + `subprocess.run`/`Popen`; server tests mock `_get_config` + `runner.dispatch`. The one exception is `TestStreamPipeHandling`, which spawns a short-lived *python* subprocess: a pipe deadlock lives in the OS pipe buffer, so a mocked `Popen` structurally cannot reproduce it.
|
|
35
35
|
|
|
36
36
|
## Non-obvious invariants (violating these breaks real behavior)
|
|
37
37
|
|
|
@@ -46,6 +46,21 @@ Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.whi
|
|
|
46
46
|
- Cancelling a *running* job requires the in-memory `_running_procs` registry (server.py) — the job is marked `cancelled` **before** the subprocess is killed. Don't persist PIDs to disk (PID reuse after restart could kill an unrelated process).
|
|
47
47
|
- `max_budget_usd` is enforced **by the claude CLI** (`_build_command` passes `--max-budget-usd`): a run stopped at the cap comes back `is_error` with no `result` text, and `_build_error_result` turns it into `error_type="budget"` + `budget_exceeded=True` + a resumable `session_id`. `_apply_budget` is the *secondary*, post-hoc signal for an overshoot that didn't stop the run; it never fails a dispatch.
|
|
48
48
|
- A CLI error payload can have no `result` field at all — the reason lives in `errors` / `subtype`. Read it via `_cli_error_details`, never assume `result` is populated on failure.
|
|
49
|
+
- **Both subprocess pipes must be drained concurrently.** `dispatch_stream` reads stdout in a loop while a daemon thread drains stderr; reading stderr only after the loop deadlocks any child that writes more than ~64 KiB to it (the child blocks in `write(2)`, never emits its result, and the dispatch dies at the timeout). `dispatch()` is immune only because `subprocess.run(capture_output=True)` uses `communicate()`.
|
|
50
|
+
- **A received `result` event outranks the timeout flag.** The agent finished and was billed; a lingering process is a cleanup problem, reported as a `hint`, not a failure.
|
|
51
|
+
- **The timeout must kill the process *tree*, not the child.** `dispatch_stream` spawns with `start_new_session=True` and `_kill_process_tree` sends SIGKILL to the group: a grandchild that inherited stdout keeps the read loop parked long past the deadline otherwise. `killpg` is guarded on a positive pid — `killpg(0)` would signal the dispatcher's own group.
|
|
52
|
+
- **Never `close()` a pipe another thread may still be reading.** `close()` waits on the reader's buffer lock with *no timeout*, so it would hang the dispatch forever — the bounded `join()` before it buys nothing. `dispatch_stream` skips the stderr close while the drain thread is alive and lets the daemon reader + Popen finalizer release the fd. This is not hypothetical: a stdio MCP server inherits the child's `stderr`, so the pipe often has no EOF even after `claude` exits cleanly.
|
|
53
|
+
- **No `await` inside `config_lock()`.** `ProcessLock`'s in-process guard is a `threading.RLock` — re-entrant per *thread* — and every MCP tool coroutine runs on the one event-loop thread. Suspending in the critical section lets a second coroutine re-enter the "held" lock and interleave its own load/mutate/save. Collect warnings as data, emit them after the `with` block (`test_no_await_inside_the_config_lock` enforces this by AST).
|
|
54
|
+
- Cross-process locks are acquired with a **bounded** wait, never a blocking `flock`: the server takes them on its event-loop thread, so a wedged holder would freeze every tool. After the deadline it proceeds unlocked and logs — a possible lost update beats a permanent freeze.
|
|
55
|
+
- `recover_stale` sweeps `pending` on a **much longer** threshold than `running`: the jobs directory is shared by every `agent-dispatch serve`, so an hours-old pending job may still be queued behind another live server's semaphore.
|
|
56
|
+
- Pydantic does **not** validate on assignment. `Field(ge=...)` guards only the *load* path; every mutation surface (CLI `add`/`update`, MCP `add_agent`/`update_agent`) needs its own boundary check, or the bound escapes as a raw `ValidationError`.
|
|
57
|
+
- Every state file (`agents.yaml`, job files) is written **temp file + `os.replace`**, never in place, and every load/mutate/save is wrapped in `config.ProcessLock` — the CLI and the MCP server are separate processes writing the same files, so a thread lock alone loses updates.
|
|
58
|
+
- Anything that changes an agent's config must call `_invalidate_agent_cache` — the cache key holds the agent *name*, not its directory or permissions.
|
|
59
|
+
- Only *clean* successes are cached: `cache.put` refuses failures, `denied_tools` results, and `budget_exceeded` results, so the documented "grant access, then re-dispatch" recovery is never short-circuited.
|
|
60
|
+
- Remediation text is a contract: a hint that names a flag must name one that exists (`test_printed_budget_hint_is_a_runnable_command` feeds the printed flags back into the CLI). Run the command you print.
|
|
61
|
+
- MCP tools that load config carry `@_config_guard` under `@mcp.tool()` so a broken `agents.yaml` returns the `{"error": ...}` envelope instead of a raw traceback.
|
|
62
|
+
|
|
63
|
+
- Tests must not touch anything outside `tmp_path`. `test_server.py`'s autouse `_reset_globals` and `test_cli.py`'s `_isolated_config` redirect **both** `AGENT_DISPATCH_CONFIG` and `AGENT_DISPATCH_JOBS_DIR`: a mutation tool that bails out early (unknown agent) still takes `config_lock()` first, which would otherwise create a lock file beside the developer's real config.
|
|
49
64
|
|
|
50
65
|
## Deliberately not built
|
|
51
66
|
|
|
@@ -61,4 +76,4 @@ Python ≥ 3.10 · `from __future__ import annotations` everywhere · Pydantic v
|
|
|
61
76
|
|
|
62
77
|
## More detail
|
|
63
78
|
|
|
64
|
-
[README.md](README.md) documents every MCP tool with parameter tables, response shapes, and the error-recovery map — it doubles as the behavioral spec. The test suite (`tests/`,
|
|
79
|
+
[README.md](README.md) documents every MCP tool with parameter tables, response shapes, and the error-recovery map — it doubles as the behavioral spec. The test suite (`tests/`, 561 tests) encodes the exact expected behavior of every layer: when in doubt, read the tests for the module you're touching (`test_runner.py`, `test_server.py`, `test_cli.py`, ...).
|
|
@@ -7,6 +7,126 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-07-29
|
|
11
|
+
|
|
12
|
+
Reliability pass over the streaming path, config durability, and the tool
|
|
13
|
+
boundary — found by an exhaustive audit of every module.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **`mcp` is capped below 2.0.** The dependency was declared as
|
|
17
|
+
`mcp[cli]>=1.2.0` with no upper bound, and `mcp` 2.0 removed
|
|
18
|
+
`mcp.server.fastmcp` (FastMCP became `mcp.server.mcpserver.MCPServer`). Any
|
|
19
|
+
fresh install therefore resolved 2.x and died with
|
|
20
|
+
`ModuleNotFoundError: No module named 'mcp.server.fastmcp'` the moment
|
|
21
|
+
`agent-dispatch serve` started — i.e. **0.11.0 was broken for new installs**.
|
|
22
|
+
Pinned to `>=1.2.0,<2` (verified against 1.29.0); the cap lifts when
|
|
23
|
+
`server.py` is ported to the 2.x API.
|
|
24
|
+
- **A timeout now actually bounds a streaming dispatch.** The timer killed only
|
|
25
|
+
the direct `claude` process. Any grandchild that inherited its stdout — a
|
|
26
|
+
backgrounded dev server, a `run_in_background` Bash call, a watcher — kept
|
|
27
|
+
that pipe from reaching EOF, so the read loop stayed parked for the
|
|
28
|
+
grandchild's entire lifetime: the deadline was ignored, the concurrency slot
|
|
29
|
+
stayed held, and `dispatch_cancel` could not free it. The child is now spawned
|
|
30
|
+
in its own process group and the timeout kills the whole tree (measured on a
|
|
31
|
+
reproduction: 20.4s → 3.0s for a 3s timeout).
|
|
32
|
+
- **Streaming no longer deadlocks on a chatty agent.** `dispatch_stream` opened
|
|
33
|
+
`stderr` as a pipe but only read it *after* the stdout loop finished. Once the
|
|
34
|
+
child wrote more than one pipe buffer (~64 KiB) of stderr — node deprecation
|
|
35
|
+
warnings, MCP server startup noise, a tool dumping a stack trace — it blocked
|
|
36
|
+
in `write(2)`, never emitted its result line, and the dispatch burned its
|
|
37
|
+
entire timeout before coming back as a bogus `error_type: "timeout"`. stderr
|
|
38
|
+
is now drained concurrently by a daemon reader. This affected the
|
|
39
|
+
`dispatch_stream` tool, `agent-dispatch test --stream`, **and every async job**
|
|
40
|
+
(the worker dispatches through the streaming runner). The pipe is never closed
|
|
41
|
+
while that reader is still blocked on it — a stdio MCP server inherits the
|
|
42
|
+
child's `stderr` and can outlive it, and `close()` waits on the reader's buffer
|
|
43
|
+
lock *without a timeout*, which would hang the dispatch outright.
|
|
44
|
+
- **A completed streaming result is no longer thrown away.** `dispatch_stream`
|
|
45
|
+
checked the timeout flag before the received result, so an agent that
|
|
46
|
+
answered — and was billed — but whose process lingered past the deadline came
|
|
47
|
+
back as a failed timeout. The result now wins; when the process had to be
|
|
48
|
+
killed after answering, that is reported as a `hint`, not a failure.
|
|
49
|
+
- **`dispatch(return_ref=True)` now honors `return_ref` on a cache hit.** It
|
|
50
|
+
returned the full cached result text inline instead of a compact ref —
|
|
51
|
+
exactly the context blow-up the caller asked to avoid. (`dispatch_parallel`
|
|
52
|
+
already handled this correctly.)
|
|
53
|
+
- **`agents.yaml` is written atomically.** `save_config` truncated the live file
|
|
54
|
+
and rewrote it in place, so an interrupted write (disk full, quota, SIGKILL)
|
|
55
|
+
left a half-written config that no longer parses — losing every agent and
|
|
56
|
+
group at once. It now writes a temp file and renames, like `JobStore`.
|
|
57
|
+
- **Concurrent config edits no longer lose each other.** Every mutation is a
|
|
58
|
+
load / mutate / save-whole-file cycle, and the CLI and the MCP server edit the
|
|
59
|
+
same file: two overlapping writers each saved their own stale snapshot, so one
|
|
60
|
+
agent silently disappeared. All mutation sites now hold a cross-process
|
|
61
|
+
advisory lock (`flock`, degrading to thread-only where unavailable). The same
|
|
62
|
+
lock now guards job-file transitions, so a CLI `cancel` can no longer be
|
|
63
|
+
overwritten by the server's `finish`. The lock is taken with a bounded wait
|
|
64
|
+
(10s) rather than a blocking `flock`, because the MCP server acquires it on
|
|
65
|
+
its event-loop thread — one wedged holder would otherwise freeze every tool;
|
|
66
|
+
no mutation suspends while holding it (the in-process guard is re-entrant per
|
|
67
|
+
*thread*, so an `await` inside the critical section would let a second
|
|
68
|
+
coroutine walk straight through it).
|
|
69
|
+
- **A symlinked `agents.yaml` keeps its symlink.** The atomic rename would have
|
|
70
|
+
replaced the link itself, silently orphaning the real file in a dotfiles repo;
|
|
71
|
+
the link is resolved before the swap.
|
|
72
|
+
- **A malformed `agents.yaml` no longer crashes every MCP tool.** A YAML syntax
|
|
73
|
+
error or a schema violation escaped as a raw protocol-level exception with no
|
|
74
|
+
remediation — including from the read-only tools an agent would use to
|
|
75
|
+
diagnose it. Tools now return the documented `{"error": ..., "hint": ...}`
|
|
76
|
+
envelope. A non-path `directory:` value raised a bare `TypeError` out of the
|
|
77
|
+
field validator (breaking the CLI too); it is now a normal validation error.
|
|
78
|
+
- **Stale results are no longer served after an agent's config changes.** The
|
|
79
|
+
cache key contains the agent *name*, not its directory, permission set or
|
|
80
|
+
model — so the documented remove-and-re-add flow kept serving the previous
|
|
81
|
+
project's answers for the rest of the TTL. `add_agent` / `update_agent` /
|
|
82
|
+
`remove_agent` now invalidate that agent's cached results.
|
|
83
|
+
- **Successful-but-degraded results are no longer cached.** A dispatch that
|
|
84
|
+
answered with `denied_tools` (or over budget) was cached for the full TTL,
|
|
85
|
+
which made the documented "grant access, then re-dispatch" recovery a no-op.
|
|
86
|
+
- **`agent-dispatch gc --days 0` (or negative) no longer silently purges every
|
|
87
|
+
terminal job** — including `return_ref` results a caller was about to fetch.
|
|
88
|
+
It is rejected, matching the `dispatch_gc` MCP tool; use the new `--all` flag
|
|
89
|
+
to purge everything on purpose.
|
|
90
|
+
- **The budget hints now name a flag that exists.** Both the runner's
|
|
91
|
+
`error_type: "budget"` hint and `agent-dispatch test` told the user to run
|
|
92
|
+
`agent-dispatch update <name> --max-budget-usd`, but the option was
|
|
93
|
+
`--max-budget`, so the copy-pasted command failed. `--max-budget-usd` is now
|
|
94
|
+
an accepted alias on `add` and `update`.
|
|
95
|
+
- **A negative timeout can no longer brick an agent.** `-5` was written straight
|
|
96
|
+
to `agents.yaml`, reached `subprocess.run(timeout=-5)`, and made every
|
|
97
|
+
dispatch fail instantly with a nonsensical "timed out after -5s". Rejected now
|
|
98
|
+
at the model, the MCP tool, and the CLI; negative budgets likewise, on both
|
|
99
|
+
`add` and `update` (they put a token starting with `-` on the `claude` command
|
|
100
|
+
line, and pydantic does not validate on assignment — the boundary checks are
|
|
101
|
+
what actually guard the mutation paths).
|
|
102
|
+
- **Jobs stuck in `pending` are recovered.** `recover_stale` only swept
|
|
103
|
+
`running`, and `gc` only deletes terminal jobs — so a job whose worker never
|
|
104
|
+
started (server killed while it was queued) stayed pending forever: an eternal
|
|
105
|
+
poll target and permanent disk growth. Pending uses a far longer threshold
|
|
106
|
+
(24×) than running: the jobs directory is shared by every `agent-dispatch
|
|
107
|
+
serve`, and an hours-old pending job may still be queued in another live
|
|
108
|
+
server.
|
|
109
|
+
- **`dispatch` survives an unexpected CLI payload.** A non-object JSON body, or
|
|
110
|
+
a `result` field that is not a string, raised out of the runner instead of
|
|
111
|
+
returning a `DispatchResult`. Exit code 0 with no output at all is now a
|
|
112
|
+
failure rather than a cached empty success.
|
|
113
|
+
- **`dispatch_parallel` returns an error envelope for a non-string `agent` or
|
|
114
|
+
`task`** instead of raising `TypeError`, and the aggregator now receives a
|
|
115
|
+
`return_ref` item's summary (labelled as a preview) instead of an empty body.
|
|
116
|
+
- The prompt is no longer scanned when locating flags: a task whose text is
|
|
117
|
+
exactly `--output-format` or `--session-id` rewrote the prompt instead of the
|
|
118
|
+
flag. The cache also hands back a copy, so a caller mutating a result cannot
|
|
119
|
+
corrupt the entry for everyone else.
|
|
120
|
+
|
|
121
|
+
### Added
|
|
122
|
+
- `agent-dispatch gc --all` — purge every terminal job regardless of age.
|
|
123
|
+
- `--max-budget-usd` as an alias for `--max-budget` on `add` and `update`.
|
|
124
|
+
- `DispatchCache.invalidate_agent(name)` and `config.ProcessLock` /
|
|
125
|
+
`config.config_lock()`.
|
|
126
|
+
- 66 tests (561 total), including real-subprocess regression tests for the
|
|
127
|
+
stderr deadlock, the inherited-pipe hang and the process-tree timeout — a
|
|
128
|
+
mocked `Popen` structurally cannot reproduce any of them.
|
|
129
|
+
|
|
10
130
|
## [0.11.0] - 2026-07-27
|
|
11
131
|
|
|
12
132
|
The spend cap was already real — now the result says so.
|
|
@@ -426,7 +546,8 @@ cache bounding, and stale-job recovery.
|
|
|
426
546
|
- Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
|
|
427
547
|
commit SHAs for supply-chain integrity.
|
|
428
548
|
|
|
429
|
-
[Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.
|
|
549
|
+
[Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.12.0...HEAD
|
|
550
|
+
[0.12.0]: https://github.com/ginkida/agent-dispatch/compare/v0.11.0...v0.12.0
|
|
430
551
|
[0.11.0]: https://github.com/ginkida/agent-dispatch/compare/v0.10.0...v0.11.0
|
|
431
552
|
[0.10.0]: https://github.com/ginkida/agent-dispatch/compare/v0.9.0...v0.10.0
|
|
432
553
|
[0.9.0]: https://github.com/ginkida/agent-dispatch/compare/v0.8.0...v0.9.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-dispatch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: MCP server that lets Claude Code agents delegate tasks to agents in other project directories
|
|
5
5
|
Project-URL: Homepage, https://github.com/ginkida/agent-dispatch
|
|
6
6
|
Project-URL: Repository, https://github.com/ginkida/agent-dispatch
|
|
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Libraries
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
22
|
Requires-Dist: click>=8.0
|
|
23
|
-
Requires-Dist: mcp[cli]
|
|
23
|
+
Requires-Dist: mcp[cli]<2,>=1.2.0
|
|
24
24
|
Requires-Dist: pyyaml>=6.0
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
@@ -348,7 +348,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
|
|
|
348
348
|
|
|
349
349
|
Same as `dispatch` but shows live progress while the agent works. Use for long-running tasks. Not cached.
|
|
350
350
|
|
|
351
|
-
Parameters are the same as `dispatch` except `return_ref`/`summary_chars` (streaming is incompatible with ref-mode).
|
|
351
|
+
Parameters are the same as `dispatch` except `return_ref`/`summary_chars` (streaming is incompatible with ref-mode) and `group` (group context injection is supported only on `dispatch` and per-item in `dispatch_parallel`).
|
|
352
352
|
|
|
353
353
|
### `dispatch_dialogue`
|
|
354
354
|
|
|
@@ -393,6 +393,8 @@ Register a new project directory as an agent. Description is auto-generated from
|
|
|
393
393
|
| `permission_mode` | string | no | Permission mode (e.g. `default`, `plan`, `bypassPermissions`) |
|
|
394
394
|
| `allowed_tools` | string | no | Comma-separated allowed tools (e.g. `"Bash,Read,Edit"`) |
|
|
395
395
|
| `disallowed_tools` | string | no | Comma-separated disallowed tools |
|
|
396
|
+
| `capabilities` | string | no | Comma-separated capability labels (e.g. `"docker_logs,deploy_debug"`) |
|
|
397
|
+
| `risky_capabilities` | string | no | Comma-separated high-risk labels (e.g. `"restart_services"`) |
|
|
396
398
|
|
|
397
399
|
### `update_agent`
|
|
398
400
|
|
|
@@ -408,6 +410,10 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
|
|
|
408
410
|
| `permission_mode` | string | no | Permission mode. `"none"` to clear |
|
|
409
411
|
| `allowed_tools` | string | no | Comma-separated. `"none"` to clear |
|
|
410
412
|
| `disallowed_tools` | string | no | Comma-separated. `"none"` to clear |
|
|
413
|
+
| `capabilities` | string | no | Comma-separated. `"none"` to clear |
|
|
414
|
+
| `risky_capabilities` | string | no | Comma-separated. `"none"` to clear |
|
|
415
|
+
|
|
416
|
+
Changing an agent's config drops that agent's cached results — the cache key holds the agent *name*, so a re-pointed or re-permissioned agent would otherwise keep answering from the previous config for the rest of the TTL. The same applies to `add_agent` and `remove_agent`.
|
|
411
417
|
|
|
412
418
|
### `remove_agent`
|
|
413
419
|
|
|
@@ -500,7 +506,7 @@ Failures are deterministic: check `success`, then branch on `error_type`.
|
|
|
500
506
|
| `error_type` | Meaning | Recovery |
|
|
501
507
|
|--------------|---------|----------|
|
|
502
508
|
| `permission` | A tool call was denied | `update_agent(name, allowed_tools="Bash,Read")` (least privilege) or `update_agent(name, permission_mode="bypassPermissions")`, then re-dispatch. The `error` text includes a hint with the exact fix. |
|
|
503
|
-
| `timeout` | Process killed at the timeout | Resume the partial work: `dispatch_session(agent, "Continue where you left off", session_id=<from the error text>)`. Or retry with a bigger `timeout_seconds=`, or use `dispatch_async`. |
|
|
509
|
+
| `timeout` | Process killed at the timeout | Resume the partial work: `dispatch_session(agent, "Continue where you left off", session_id=<from the error text>)`. Or retry with a bigger `timeout_seconds=`, or use `dispatch_async`. A *streaming* dispatch that produced its answer before the deadline returns that answer with a `hint` instead of failing. |
|
|
504
510
|
| `not_found` | Agent directory or `claude` CLI missing | `list_agents()` → check `healthy`. Re-add the agent with an existing path, or run `agent-dispatch doctor` to find what's missing. |
|
|
505
511
|
| `recursion` | Dispatch nesting exceeded `max_dispatch_depth` (default 3) | Don't dispatch from dispatched agents; if the nesting is intentional, raise `max_dispatch_depth` in settings. |
|
|
506
512
|
| `budget` | The `claude` CLI ended the session at the `max_budget_usd` spend cap — the answer is incomplete | Raise the cap (`update_agent(name, max_budget_usd=2.0)`), switch to a cheaper `model`, or split the task. The partial session is resumable: `dispatch_session(agent, "Continue where you left off", session_id=<from the result>)`. |
|
|
@@ -627,8 +633,9 @@ agent-dispatch MCP server
|
|
|
627
633
|
- **Owner-only state** — job files (`0o600`) and `agents.yaml` (`0o600`) are written for the owner only; their directories are `0o700`.
|
|
628
634
|
- **Cost control** — `max_budget_usd` per agent or globally is passed to the `claude` CLI as `--max-budget-usd`, so a runaway dispatch is stopped at the cap and comes back as `error_type: "budget"` with a resumable `session_id`. An overshoot that lands over budget without stopping is flagged post-hoc with `budget_exceeded: true` + a hint.
|
|
629
635
|
- **Concurrency** — `max_concurrency` (default: 5) caps parallel `claude -p` processes. Note: the sync and async dispatch paths use separate semaphores, so the worst-case total is `2 × max_concurrency`.
|
|
630
|
-
- **Timeout** — per-agent or global (default: 300s).
|
|
631
|
-
- **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only successes are cached. Sessions and dialogues are never cached. A `group=` dispatch folds the group's `shared_context` into `context`, so different groups cache separately and a plain dispatch is unaffected.
|
|
636
|
+
- **Timeout** — per-agent or global (default: 300s). A streaming dispatch runs the agent in its own process group, so the deadline kills the whole tree: a process the agent left running in the background can't hold the dispatch (and its concurrency slot) open past the timeout.
|
|
637
|
+
- **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only clean successes are cached: failures, results with `denied_tools`, and results flagged `budget_exceeded` are not, so the documented "grant access / raise the cap, then re-dispatch" recovery is never served a stale crippled answer. Changing an agent's config invalidates its entries. Sessions and dialogues are never cached. A `group=` dispatch folds the group's `shared_context` into `context`, so different groups cache separately and a plain dispatch is unaffected.
|
|
638
|
+
- **Durable config** — `agents.yaml` is written atomically (temp file + rename), and every mutation path (CLI and MCP server alike) holds a cross-process advisory lock, so concurrent edits cannot truncate the file or silently drop one another's agents.
|
|
632
639
|
|
|
633
640
|
See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
|
|
634
641
|
|
|
@@ -648,7 +655,7 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
|
|
|
648
655
|
| `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
|
|
649
656
|
| `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
|
|
650
657
|
| `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
|
|
651
|
-
| `agent-dispatch gc [--days]` | Purge terminal jobs older than N days (default 7) |
|
|
658
|
+
| `agent-dispatch gc [--days N \| --all]` | Purge terminal jobs older than N days (default 7; `--all` purges every age) |
|
|
652
659
|
| `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
|
|
653
660
|
|
|
654
661
|
## Requirements
|
|
@@ -318,7 +318,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
|
|
|
318
318
|
|
|
319
319
|
Same as `dispatch` but shows live progress while the agent works. Use for long-running tasks. Not cached.
|
|
320
320
|
|
|
321
|
-
Parameters are the same as `dispatch` except `return_ref`/`summary_chars` (streaming is incompatible with ref-mode).
|
|
321
|
+
Parameters are the same as `dispatch` except `return_ref`/`summary_chars` (streaming is incompatible with ref-mode) and `group` (group context injection is supported only on `dispatch` and per-item in `dispatch_parallel`).
|
|
322
322
|
|
|
323
323
|
### `dispatch_dialogue`
|
|
324
324
|
|
|
@@ -363,6 +363,8 @@ Register a new project directory as an agent. Description is auto-generated from
|
|
|
363
363
|
| `permission_mode` | string | no | Permission mode (e.g. `default`, `plan`, `bypassPermissions`) |
|
|
364
364
|
| `allowed_tools` | string | no | Comma-separated allowed tools (e.g. `"Bash,Read,Edit"`) |
|
|
365
365
|
| `disallowed_tools` | string | no | Comma-separated disallowed tools |
|
|
366
|
+
| `capabilities` | string | no | Comma-separated capability labels (e.g. `"docker_logs,deploy_debug"`) |
|
|
367
|
+
| `risky_capabilities` | string | no | Comma-separated high-risk labels (e.g. `"restart_services"`) |
|
|
366
368
|
|
|
367
369
|
### `update_agent`
|
|
368
370
|
|
|
@@ -378,6 +380,10 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
|
|
|
378
380
|
| `permission_mode` | string | no | Permission mode. `"none"` to clear |
|
|
379
381
|
| `allowed_tools` | string | no | Comma-separated. `"none"` to clear |
|
|
380
382
|
| `disallowed_tools` | string | no | Comma-separated. `"none"` to clear |
|
|
383
|
+
| `capabilities` | string | no | Comma-separated. `"none"` to clear |
|
|
384
|
+
| `risky_capabilities` | string | no | Comma-separated. `"none"` to clear |
|
|
385
|
+
|
|
386
|
+
Changing an agent's config drops that agent's cached results — the cache key holds the agent *name*, so a re-pointed or re-permissioned agent would otherwise keep answering from the previous config for the rest of the TTL. The same applies to `add_agent` and `remove_agent`.
|
|
381
387
|
|
|
382
388
|
### `remove_agent`
|
|
383
389
|
|
|
@@ -470,7 +476,7 @@ Failures are deterministic: check `success`, then branch on `error_type`.
|
|
|
470
476
|
| `error_type` | Meaning | Recovery |
|
|
471
477
|
|--------------|---------|----------|
|
|
472
478
|
| `permission` | A tool call was denied | `update_agent(name, allowed_tools="Bash,Read")` (least privilege) or `update_agent(name, permission_mode="bypassPermissions")`, then re-dispatch. The `error` text includes a hint with the exact fix. |
|
|
473
|
-
| `timeout` | Process killed at the timeout | Resume the partial work: `dispatch_session(agent, "Continue where you left off", session_id=<from the error text>)`. Or retry with a bigger `timeout_seconds=`, or use `dispatch_async`. |
|
|
479
|
+
| `timeout` | Process killed at the timeout | Resume the partial work: `dispatch_session(agent, "Continue where you left off", session_id=<from the error text>)`. Or retry with a bigger `timeout_seconds=`, or use `dispatch_async`. A *streaming* dispatch that produced its answer before the deadline returns that answer with a `hint` instead of failing. |
|
|
474
480
|
| `not_found` | Agent directory or `claude` CLI missing | `list_agents()` → check `healthy`. Re-add the agent with an existing path, or run `agent-dispatch doctor` to find what's missing. |
|
|
475
481
|
| `recursion` | Dispatch nesting exceeded `max_dispatch_depth` (default 3) | Don't dispatch from dispatched agents; if the nesting is intentional, raise `max_dispatch_depth` in settings. |
|
|
476
482
|
| `budget` | The `claude` CLI ended the session at the `max_budget_usd` spend cap — the answer is incomplete | Raise the cap (`update_agent(name, max_budget_usd=2.0)`), switch to a cheaper `model`, or split the task. The partial session is resumable: `dispatch_session(agent, "Continue where you left off", session_id=<from the result>)`. |
|
|
@@ -597,8 +603,9 @@ agent-dispatch MCP server
|
|
|
597
603
|
- **Owner-only state** — job files (`0o600`) and `agents.yaml` (`0o600`) are written for the owner only; their directories are `0o700`.
|
|
598
604
|
- **Cost control** — `max_budget_usd` per agent or globally is passed to the `claude` CLI as `--max-budget-usd`, so a runaway dispatch is stopped at the cap and comes back as `error_type: "budget"` with a resumable `session_id`. An overshoot that lands over budget without stopping is flagged post-hoc with `budget_exceeded: true` + a hint.
|
|
599
605
|
- **Concurrency** — `max_concurrency` (default: 5) caps parallel `claude -p` processes. Note: the sync and async dispatch paths use separate semaphores, so the worst-case total is `2 × max_concurrency`.
|
|
600
|
-
- **Timeout** — per-agent or global (default: 300s).
|
|
601
|
-
- **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only successes are cached. Sessions and dialogues are never cached. A `group=` dispatch folds the group's `shared_context` into `context`, so different groups cache separately and a plain dispatch is unaffected.
|
|
606
|
+
- **Timeout** — per-agent or global (default: 300s). A streaming dispatch runs the agent in its own process group, so the deadline kills the whole tree: a process the agent left running in the background can't hold the dispatch (and its concurrency slot) open past the timeout.
|
|
607
|
+
- **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only clean successes are cached: failures, results with `denied_tools`, and results flagged `budget_exceeded` are not, so the documented "grant access / raise the cap, then re-dispatch" recovery is never served a stale crippled answer. Changing an agent's config invalidates its entries. Sessions and dialogues are never cached. A `group=` dispatch folds the group's `shared_context` into `context`, so different groups cache separately and a plain dispatch is unaffected.
|
|
608
|
+
- **Durable config** — `agents.yaml` is written atomically (temp file + rename), and every mutation path (CLI and MCP server alike) holds a cross-process advisory lock, so concurrent edits cannot truncate the file or silently drop one another's agents.
|
|
602
609
|
|
|
603
610
|
See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
|
|
604
611
|
|
|
@@ -618,7 +625,7 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
|
|
|
618
625
|
| `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
|
|
619
626
|
| `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
|
|
620
627
|
| `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
|
|
621
|
-
| `agent-dispatch gc [--days]` | Purge terminal jobs older than N days (default 7) |
|
|
628
|
+
| `agent-dispatch gc [--days N \| --all]` | Purge terminal jobs older than N days (default 7; `--all` purges every age) |
|
|
622
629
|
| `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
|
|
623
630
|
|
|
624
631
|
## Requirements
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agent-dispatch"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0"
|
|
4
4
|
description = "MCP server that lets Claude Code agents delegate tasks to agents in other project directories"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -32,7 +32,11 @@ classifiers = [
|
|
|
32
32
|
"Topic :: Software Development :: Libraries",
|
|
33
33
|
]
|
|
34
34
|
dependencies = [
|
|
35
|
-
|
|
35
|
+
# Upper bound is load-bearing: mcp 2.0 removed `mcp.server.fastmcp` (FastMCP
|
|
36
|
+
# was replaced by `mcp.server.mcpserver.MCPServer`), which server.py imports.
|
|
37
|
+
# Without the cap a fresh install resolves 2.x and `agent-dispatch serve`
|
|
38
|
+
# dies with ModuleNotFoundError. Lift it only together with the port.
|
|
39
|
+
"mcp[cli]>=1.2.0,<2",
|
|
36
40
|
"pyyaml>=6.0",
|
|
37
41
|
"click>=8.0",
|
|
38
42
|
]
|
|
@@ -26,7 +26,10 @@ class DispatchCache:
|
|
|
26
26
|
def __init__(self, ttl: int = 300, max_size: int = 1000) -> None:
|
|
27
27
|
self._ttl = ttl
|
|
28
28
|
self._max_size = max_size
|
|
29
|
-
|
|
29
|
+
# value = (stored_at, agent_name, result). The agent name is kept
|
|
30
|
+
# alongside the hashed key so invalidate_agent() can drop exactly one
|
|
31
|
+
# agent's entries when its config changes.
|
|
32
|
+
self._store: dict[str, tuple[float, str, DispatchResult]] = {}
|
|
30
33
|
self._lock = threading.Lock()
|
|
31
34
|
self._hits = 0
|
|
32
35
|
self._misses = 0
|
|
@@ -69,13 +72,16 @@ class DispatchCache:
|
|
|
69
72
|
if entry is None:
|
|
70
73
|
self._misses += 1
|
|
71
74
|
return None
|
|
72
|
-
ts, result = entry
|
|
75
|
+
ts, _agent, result = entry
|
|
73
76
|
if time.monotonic() - ts > self._ttl:
|
|
74
77
|
del self._store[key]
|
|
75
78
|
self._misses += 1
|
|
76
79
|
return None
|
|
77
80
|
self._hits += 1
|
|
78
|
-
|
|
81
|
+
# Hand back a copy: the stored result is shared by every caller of
|
|
82
|
+
# this key, and a caller that mutates what it got (adding a flag,
|
|
83
|
+
# truncating text) would silently corrupt the entry for everyone.
|
|
84
|
+
return result.model_copy(deep=True)
|
|
79
85
|
|
|
80
86
|
def put(
|
|
81
87
|
self,
|
|
@@ -89,6 +95,13 @@ class DispatchCache:
|
|
|
89
95
|
) -> None:
|
|
90
96
|
if not result.success:
|
|
91
97
|
return # don't cache failures
|
|
98
|
+
if result.denied_tools or result.budget_exceeded:
|
|
99
|
+
# Successful but degraded: the agent answered with tools blocked, or
|
|
100
|
+
# the run cost more than its cap. The documented recovery is "grant
|
|
101
|
+
# access / raise the budget, then re-dispatch" — caching this would
|
|
102
|
+
# serve the same crippled answer back for the whole TTL and make that
|
|
103
|
+
# recovery a no-op (the permission config is not part of the key).
|
|
104
|
+
return
|
|
92
105
|
key = self._make_key(agent, task, context, caller, goal, response_format)
|
|
93
106
|
with self._lock:
|
|
94
107
|
# Bound memory: when at capacity and inserting a new key, evict the
|
|
@@ -100,7 +113,22 @@ class DispatchCache:
|
|
|
100
113
|
oldest = min(self._store, key=lambda k: self._store[k][0])
|
|
101
114
|
del self._store[oldest]
|
|
102
115
|
self._evictions += 1
|
|
103
|
-
self._store[key] = (time.monotonic(), result)
|
|
116
|
+
self._store[key] = (time.monotonic(), agent, result)
|
|
117
|
+
|
|
118
|
+
def invalidate_agent(self, agent: str) -> int:
|
|
119
|
+
"""Drop every cached result for *agent*. Returns the number removed.
|
|
120
|
+
|
|
121
|
+
Called whenever an agent's config changes: the cache key is
|
|
122
|
+
(agent, task, context, caller, goal, response_format), so it cannot tell
|
|
123
|
+
that the name now points at a different directory, permission set or
|
|
124
|
+
model. Without this, ``remove_agent`` + ``add_agent`` under the same name
|
|
125
|
+
keeps serving the previous project's answers for the rest of the TTL.
|
|
126
|
+
"""
|
|
127
|
+
with self._lock:
|
|
128
|
+
stale = [k for k, (_ts, name, _r) in self._store.items() if name == agent]
|
|
129
|
+
for k in stale:
|
|
130
|
+
del self._store[k]
|
|
131
|
+
return len(stale)
|
|
104
132
|
|
|
105
133
|
def clear(self) -> int:
|
|
106
134
|
with self._lock:
|
|
@@ -114,7 +142,7 @@ class DispatchCache:
|
|
|
114
142
|
def evict_expired(self) -> int:
|
|
115
143
|
now = time.monotonic()
|
|
116
144
|
with self._lock:
|
|
117
|
-
expired = [k for k, (ts,
|
|
145
|
+
expired = [k for k, (ts, _n, _r) in self._store.items() if now - ts > self._ttl]
|
|
118
146
|
for k in expired:
|
|
119
147
|
del self._store[k]
|
|
120
148
|
return len(expired)
|