agent-dispatch 0.10.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.
Files changed (31) hide show
  1. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/.github/workflows/ci.yml +2 -2
  2. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/.github/workflows/publish.yml +2 -2
  3. agent_dispatch-0.12.0/AGENTS.md +79 -0
  4. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/CHANGELOG.md +163 -1
  5. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/PKG-INFO +18 -10
  6. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/README.md +16 -8
  7. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/agents.example.yaml +2 -1
  8. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/pyproject.toml +6 -2
  9. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/__init__.py +1 -1
  10. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/cache.py +33 -5
  11. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/cli.py +211 -87
  12. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/config.py +150 -5
  13. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/jobs.py +61 -21
  14. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/models.py +21 -6
  15. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/runner.py +310 -78
  16. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/server.py +230 -40
  17. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_cache.py +82 -13
  18. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_cli.py +144 -0
  19. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_config.py +146 -1
  20. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_jobs.py +137 -9
  21. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_models.py +41 -0
  22. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_runner.py +732 -152
  23. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_server.py +313 -0
  24. agent_dispatch-0.10.0/AGENTS.md +0 -58
  25. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/.github/dependabot.yml +0 -0
  26. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/.gitignore +0 -0
  27. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/LICENSE +0 -0
  28. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/SECURITY.md +0 -0
  29. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/assets/mascot.png +0 -0
  30. {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/__init__.py +0 -0
  31. {agent_dispatch-0.10.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
21
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
22
22
 
23
- - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
18
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
19
19
 
20
- - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
20
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
21
21
  with:
22
22
  python-version: "3.12"
23
23
 
@@ -0,0 +1,79 @@
1
+ # AGENTS.md
2
+
3
+ Guidance for AI coding agents working on this repository.
4
+
5
+ > **Using agent-dispatch** (not developing it)? Read [README.md](README.md) — it has the full setup path with verify steps and the complete MCP tool reference. This file is for contributing to the codebase.
6
+
7
+ ## What this project is
8
+
9
+ MCP server + CLI that lets Claude Code agents delegate tasks to agents in other project directories. One sync core, two surfaces:
10
+
11
+ | File | Role |
12
+ |------|------|
13
+ | `src/agent_dispatch/runner.py` | Sync subprocess wrapper around `claude -p` — the actual work |
14
+ | `src/agent_dispatch/server.py` | Async FastMCP interface (21 MCP tools), wraps runner in `asyncio.to_thread` + semaphore |
15
+ | `src/agent_dispatch/cli.py` | Click CLI: `init`, `add`, `update`, `remove`, `list`, `describe`, `test`, `doctor`, `jobs`, `job`, `cancel`, `gc`, `group` (add/list/inspect/update/remove), `serve` |
16
+ | `src/agent_dispatch/models.py` | Pydantic v2 models (`AgentConfig`, `DispatchGroup`/`GroupMember`, `Settings`, `DispatchResult`) |
17
+ | `src/agent_dispatch/config.py` | YAML config load/save + project auto-description |
18
+ | `src/agent_dispatch/cache.py` | Thread-safe in-memory TTL cache |
19
+ | `src/agent_dispatch/jobs.py` | Persistent per-job JSON files for async dispatch |
20
+
21
+ ## Dev setup
22
+
23
+ ```bash
24
+ pip install -e ".[dev]"
25
+ ```
26
+
27
+ ## Gates — both must pass before a change is done (CI rejects otherwise)
28
+
29
+ ```bash
30
+ ruff check src/ tests/
31
+ python3 -m pytest tests/ -v # 561 tests, ~4s
32
+ ```
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`. 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
+
36
+ ## Non-obvious invariants (violating these breaks real behavior)
37
+
38
+ - `allowed_tools` / `disallowed_tools` are **tri-state**: `None` = inherit settings defaults, `[]` = explicitly no tools, `[...]` = exactly these. Check with `is not None`, never `or` — `[]` is falsy but semantically distinct.
39
+ - Error-type precedence on an `is_error` payload (`_build_error_result`): the CLI's own budget stop wins, then `denied_tools` non-empty ⇒ `error_type="permission"` regardless of the error text, then text classification.
40
+ - **Groups**: a group's `shared_context` is folded into the `context` *string* before the cache/runner calls (`_merge_group_context` in server.py) — runner.py and cache.py are untouched, the cache key disambiguates groups for free, and `group=""` is byte-identical to a plain dispatch. Membership is validated up front (`_validate_group_member`, separate from the pure merge so `dispatch_parallel`'s all-or-nothing pre-check holds). `DispatchConfig` validates only group *keys*, never member existence — a hard cross-ref check would brick config load when a shared gateway agent is removed; dangling refs are flagged (`unknown:true`) at read time instead.
41
+ - On failure, callers read `DispatchResult.error` + `error_type` — `result` holds the raw agent output even on errors.
42
+ - `--session-id` and `--resume` conflict — never pass both to `claude`.
43
+ - Valid permission modes: `default`, `plan`, `bypassPermissions` (`models.py: KNOWN_PERMISSION_MODES`).
44
+ - `JobStore.finish`/`fail` refuse already-terminal jobs (returns `None`) — this closes the race with force-cancel; never "fix" it by overwriting. `mark_running` likewise refuses any job that isn't `pending`, so a stale or duplicate worker can't resurrect a finished one.
45
+ - "Is this group member missing?" has exactly one implementation: `DispatchConfig.unknown_group_members()`. Any new surface that lists or validates membership calls it instead of re-deriving the check.
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
+ - `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
+ - 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.
64
+
65
+ ## Deliberately not built
66
+
67
+ These were considered — some fully implemented — and cut on purpose: an agent router / auto-dispatch (`recommend_agent` / `dispatch_auto`, removed before 0.8.0 — a keyword scorer adds little over the calling LLM at a handful of agents, and auto-dispatch can spend money or mutate a repo on a guess); groups as an execution engine (they are a descriptive layer — no routing, no per-group settings); an agent-dispatch-side budget ledger across dispatches (the CLI's own `--max-budget-usd` covers a single run; anything cumulative would need state we deliberately don't keep). Please open an issue with the use case before adding any of them.
68
+
69
+ ## Conventions
70
+
71
+ Python ≥ 3.10 · `from __future__ import annotations` everywhere · Pydantic v2 · Click (CLI) + FastMCP (server) · ruff, line length 100 · all MCP tools return JSON strings, errors as `{"error": "..."}`.
72
+
73
+ ## When adding a feature, check every layer
74
+
75
+ `models.py` (data shape) → `config.py` (YAML round-trip + empty-collection pruning) → `runner.py` (dispatch mechanics) → `server.py` (MCP tool) → `cli.py` (CLI flag) → tests for each → `README.md` + `agents.example.yaml` (user docs).
76
+
77
+ ## More detail
78
+
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,163 @@ 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
+
130
+ ## [0.11.0] - 2026-07-27
131
+
132
+ The spend cap was already real — now the result says so.
133
+
134
+ ### Added
135
+ - **`error_type: "budget"`.** The `claude` CLI enforces the `--max-budget-usd`
136
+ value agent-dispatch has always passed: when the cap is reached it ends the
137
+ session and reports the failure with no `result` text. That payload used to
138
+ surface as the useless `"reported an error with no details"` fallback with
139
+ `error_type: "cli_error"`. It is now classified as `budget`, carries the
140
+ CLI's own reason (`Reached maximum budget ($X)`), sets
141
+ `budget_exceeded: true`, and spells out the recovery — raise the cap, pick a
142
+ cheaper model, or resume the partial session via the returned `session_id`.
143
+ `agent-dispatch test` prints a matching diagnosis.
144
+
145
+ ### Fixed
146
+ - **Error details are no longer dropped.** CLI-level failures (budget
147
+ exhausted, max turns, execution errors) put their reason in `errors` /
148
+ `subtype` rather than `result`. Both `dispatch` and `dispatch_stream` now
149
+ read those fields when `result` is empty, so the error text names the actual
150
+ problem. Values are capped (5 entries × 300 chars) like every other field
151
+ read back from the subprocess.
152
+ - **Job files can no longer be corrupted by a concurrent writer.** `JobStore`
153
+ wrote through a fixed `<id>.tmp` path; the in-process lock does not cover
154
+ the CLI (`cancel` / `gc`) and the MCP server writing the same job at the
155
+ same time, and the rename could publish interleaved JSON. Each write now
156
+ uses a unique temp name and cleans it up if the write fails.
157
+
158
+ ### Changed
159
+ - The `is_error` handling in `dispatch` and `dispatch_stream` is now one
160
+ shared `_build_error_result()` with an explicit precedence: budget stop >
161
+ denied tools > text classification.
162
+ - Documentation corrected throughout: `max_budget_usd` is enforced per
163
+ dispatch by the CLI, with the `budget_exceeded` flag as the secondary
164
+ post-hoc signal for a run that overshot without being stopped. Previous
165
+ releases described it as post-hoc only.
166
+
10
167
  ## [0.10.0] - 2026-07-14
11
168
 
12
169
  `doctor` learns to check groups; three correctness fixes found in review.
@@ -389,7 +546,12 @@ cache bounding, and stale-job recovery.
389
546
  - Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
390
547
  commit SHAs for supply-chain integrity.
391
548
 
392
- [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.6.0...HEAD
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
551
+ [0.11.0]: https://github.com/ginkida/agent-dispatch/compare/v0.10.0...v0.11.0
552
+ [0.10.0]: https://github.com/ginkida/agent-dispatch/compare/v0.9.0...v0.10.0
553
+ [0.9.0]: https://github.com/ginkida/agent-dispatch/compare/v0.8.0...v0.9.0
554
+ [0.8.0]: https://github.com/ginkida/agent-dispatch/compare/v0.6.0...v0.8.0
393
555
  [0.6.0]: https://github.com/ginkida/agent-dispatch/compare/v0.5.0...v0.6.0
394
556
  [0.5.0]: https://github.com/ginkida/agent-dispatch/compare/v0.4.0...v0.5.0
395
557
  [0.4.0]: https://github.com/ginkida/agent-dispatch/compare/v0.3.0...v0.4.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.10.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]>=1.2.0
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'
@@ -233,7 +233,7 @@ dispatch(
233
233
  }
234
234
  ```
235
235
 
236
- **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
236
+ **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `budget` (the `claude` CLI stopped the session at `max_budget_usd`), `cli_error` (other failures). Permission and budget errors include an actionable hint.
237
237
 
238
238
  **Resumable timeouts:** every fresh dispatch pre-assigns a session UUID (`--session-id`), so a timed-out dispatch still returns a `session_id` — the partial transcript survives the kill. The timeout error spells out the recovery: resume with `dispatch_session(agent, "Continue where you left off", session_id=...)`, retry with a bigger `timeout_seconds`, or use `dispatch_async`.
239
239
 
@@ -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,16 +506,17 @@ 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. |
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>)`. |
506
513
  | `cli_error` | Anything else from the `claude` subprocess | Read the `error` text; run `agent-dispatch doctor` for environment issues; retry once if transient. |
507
514
 
508
515
  Three soft signals that arrive with `success: true`:
509
516
 
510
517
  - **`denied_tools` + `hint`** — the agent finished but some tool calls were blocked; the result may be incomplete. Grant access (see the `permission` row) and re-dispatch.
511
518
  - **`parsed_result: null` with `response_format="json"`** — the reply wasn't valid JSON; the raw text is still in `result`. Caveat: an agent that *can't* comply returns `{"error": "<reason>"}` — which parses successfully — so also check `parsed_result` for an `"error"` key.
512
- - **`budget_exceeded: true`** — `cost_usd` exceeded the agent's `max_budget_usd` (or the settings default). The dispatch is not failed — the money is already spent — but a runaway agent is now visible. Tighten the task, pick a cheaper model, or raise the budget.
519
+ - **`budget_exceeded: true`** — `cost_usd` came in over the agent's `max_budget_usd` (or the settings default) without the CLI stopping the run (the final turn can overshoot the cap). The dispatch is not failed — the money is already spent — but a runaway agent is now visible. Tighten the task, pick a cheaper model, or raise the budget. A run the CLI *did* stop fails with `error_type: "budget"` instead.
513
520
 
514
521
  Tool-level errors (unknown agent, malformed input) return a plain envelope instead of a `DispatchResult`:
515
522
 
@@ -624,10 +631,11 @@ agent-dispatch MCP server
624
631
  - **Argument-injection guard** — structured CLI fields (`session_id`, `model`, `permission_mode`, tool names) that start with `-` are rejected so they can't smuggle extra `claude` flags.
625
632
  - **Path-traversal guard** — caller-supplied `job_id`/`ref` values are validated as 32-char hex before any filesystem access.
626
633
  - **Owner-only state** — job files (`0o600`) and `agents.yaml` (`0o600`) are written for the owner only; their directories are `0o700`.
627
- - **Cost visibility** — `max_budget_usd` per agent or globally; a dispatch whose cost exceeds it returns `budget_exceeded: true` + a hint (post-hoc — the `claude` CLI has no spend cap, so the overage can be flagged but not prevented).
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.
628
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`.
629
- - **Timeout** — per-agent or global (default: 300s). Orphaned processes are cleaned up.
630
- - **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.
631
639
 
632
640
  See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
633
641
 
@@ -647,7 +655,7 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
647
655
  | `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
648
656
  | `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
649
657
  | `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
650
- | `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) |
651
659
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
652
660
 
653
661
  ## Requirements
@@ -203,7 +203,7 @@ dispatch(
203
203
  }
204
204
  ```
205
205
 
206
- **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
206
+ **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `budget` (the `claude` CLI stopped the session at `max_budget_usd`), `cli_error` (other failures). Permission and budget errors include an actionable hint.
207
207
 
208
208
  **Resumable timeouts:** every fresh dispatch pre-assigns a session UUID (`--session-id`), so a timed-out dispatch still returns a `session_id` — the partial transcript survives the kill. The timeout error spells out the recovery: resume with `dispatch_session(agent, "Continue where you left off", session_id=...)`, retry with a bigger `timeout_seconds`, or use `dispatch_async`.
209
209
 
@@ -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,16 +476,17 @@ 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. |
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>)`. |
476
483
  | `cli_error` | Anything else from the `claude` subprocess | Read the `error` text; run `agent-dispatch doctor` for environment issues; retry once if transient. |
477
484
 
478
485
  Three soft signals that arrive with `success: true`:
479
486
 
480
487
  - **`denied_tools` + `hint`** — the agent finished but some tool calls were blocked; the result may be incomplete. Grant access (see the `permission` row) and re-dispatch.
481
488
  - **`parsed_result: null` with `response_format="json"`** — the reply wasn't valid JSON; the raw text is still in `result`. Caveat: an agent that *can't* comply returns `{"error": "<reason>"}` — which parses successfully — so also check `parsed_result` for an `"error"` key.
482
- - **`budget_exceeded: true`** — `cost_usd` exceeded the agent's `max_budget_usd` (or the settings default). The dispatch is not failed — the money is already spent — but a runaway agent is now visible. Tighten the task, pick a cheaper model, or raise the budget.
489
+ - **`budget_exceeded: true`** — `cost_usd` came in over the agent's `max_budget_usd` (or the settings default) without the CLI stopping the run (the final turn can overshoot the cap). The dispatch is not failed — the money is already spent — but a runaway agent is now visible. Tighten the task, pick a cheaper model, or raise the budget. A run the CLI *did* stop fails with `error_type: "budget"` instead.
483
490
 
484
491
  Tool-level errors (unknown agent, malformed input) return a plain envelope instead of a `DispatchResult`:
485
492
 
@@ -594,10 +601,11 @@ agent-dispatch MCP server
594
601
  - **Argument-injection guard** — structured CLI fields (`session_id`, `model`, `permission_mode`, tool names) that start with `-` are rejected so they can't smuggle extra `claude` flags.
595
602
  - **Path-traversal guard** — caller-supplied `job_id`/`ref` values are validated as 32-char hex before any filesystem access.
596
603
  - **Owner-only state** — job files (`0o600`) and `agents.yaml` (`0o600`) are written for the owner only; their directories are `0o700`.
597
- - **Cost visibility** — `max_budget_usd` per agent or globally; a dispatch whose cost exceeds it returns `budget_exceeded: true` + a hint (post-hoc — the `claude` CLI has no spend cap, so the overage can be flagged but not prevented).
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.
598
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`.
599
- - **Timeout** — per-agent or global (default: 300s). Orphaned processes are cleaned up.
600
- - **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.
601
609
 
602
610
  See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
603
611
 
@@ -617,7 +625,7 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
617
625
  | `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
618
626
  | `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
619
627
  | `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
620
- | `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) |
621
629
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
622
630
 
623
631
  ## Requirements
@@ -93,7 +93,8 @@ settings:
93
93
  default_timeout: 300
94
94
  max_dispatch_depth: 3 # recursion protection: A -> B -> A
95
95
  max_concurrency: 5 # max parallel claude -p processes
96
- # default_max_budget_usd: 1.0 # flags results over this cost (budget_exceeded + hint; post-hoc, can't prevent the spend)
96
+ # default_max_budget_usd: 1.0 # spend cap per dispatch, passed to claude as --max-budget-usd
97
+ # (a run stopped at the cap fails with error_type: budget)
97
98
  # default_permission_mode: bypassPermissions # inherited by agents without override
98
99
  # default_allowed_tools: # inherited by agents without override
99
100
  # - Bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.10.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
- "mcp[cli]>=1.2.0",
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
  ]
@@ -1,3 +1,3 @@
1
1
  """agent-dispatch: Delegate tasks between Claude Code agents across projects."""
2
2
 
3
- __version__ = "0.10.0"
3
+ __version__ = "0.12.0"
@@ -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
- self._store: dict[str, tuple[float, DispatchResult]] = {}
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
- return result
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, _) in self._store.items() if now - ts > self._ttl]
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)