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.
Files changed (30) hide show
  1. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/.github/workflows/ci.yml +2 -2
  2. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/.github/workflows/publish.yml +2 -2
  3. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/AGENTS.md +18 -3
  4. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/CHANGELOG.md +122 -1
  5. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/PKG-INFO +14 -7
  6. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/README.md +12 -5
  7. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/pyproject.toml +6 -2
  8. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/__init__.py +1 -1
  9. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/cache.py +33 -5
  10. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/cli.py +205 -87
  11. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/config.py +150 -5
  12. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/jobs.py +45 -17
  13. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/models.py +20 -5
  14. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/runner.py +163 -20
  15. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/src/agent_dispatch/server.py +230 -40
  16. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_cache.py +82 -13
  17. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_cli.py +126 -0
  18. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_config.py +146 -1
  19. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_jobs.py +79 -0
  20. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_models.py +41 -0
  21. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_runner.py +233 -2
  22. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/test_server.py +313 -0
  23. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/.github/dependabot.yml +0 -0
  24. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/.gitignore +0 -0
  25. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/LICENSE +0 -0
  26. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/SECURITY.md +0 -0
  27. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/agents.example.yaml +0 -0
  28. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/assets/mascot.png +0 -0
  29. {agent_dispatch-0.11.0 → agent_dispatch-0.12.0}/tests/__init__.py +0 -0
  30. {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@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
 
@@ -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 # 495 tests, ~2s — all subprocess calls are mocked
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/`, 495 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`, ...).
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.11.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
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.11.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'
@@ -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). Orphaned processes are cleaned up.
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). Orphaned processes are cleaned up.
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.11.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.11.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)