agent-dispatch 0.11.0__tar.gz → 0.12.1__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.1}/.github/workflows/ci.yml +2 -2
  2. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/.github/workflows/publish.yml +2 -2
  3. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/AGENTS.md +18 -3
  4. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/CHANGELOG.md +168 -1
  5. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/PKG-INFO +18 -11
  6. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/README.md +16 -9
  7. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/pyproject.toml +6 -2
  8. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/src/agent_dispatch/__init__.py +1 -1
  9. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/src/agent_dispatch/cache.py +33 -5
  10. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/src/agent_dispatch/cli.py +244 -87
  11. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/src/agent_dispatch/config.py +159 -5
  12. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/src/agent_dispatch/jobs.py +45 -17
  13. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/src/agent_dispatch/models.py +20 -5
  14. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/src/agent_dispatch/runner.py +191 -20
  15. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/src/agent_dispatch/server.py +279 -44
  16. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/tests/test_cache.py +82 -13
  17. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/tests/test_cli.py +184 -0
  18. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/tests/test_config.py +146 -1
  19. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/tests/test_jobs.py +79 -0
  20. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/tests/test_models.py +41 -0
  21. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/tests/test_runner.py +303 -2
  22. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/tests/test_server.py +411 -0
  23. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/.github/dependabot.yml +0 -0
  24. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/.gitignore +0 -0
  25. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/LICENSE +0 -0
  26. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/SECURITY.md +0 -0
  27. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/agents.example.yaml +0 -0
  28. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/assets/mascot.png +0 -0
  29. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/tests/__init__.py +0 -0
  30. {agent_dispatch-0.11.0 → agent_dispatch-0.12.1}/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 # 578 tests, ~5s
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` — or a failed *write* — returns the `{"error": ...}` envelope instead of a raw traceback. The set of load errors lives in one place (`config.CONFIG_LOAD_ERRORS`) because three surfaces handle it: **`UnicodeDecodeError` is a `ValueError`, not an `OSError`**, and listing types per-site is exactly how a cp1251 config slipped past all three.
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/`, 578 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,171 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.1] - 2026-07-30
11
+
12
+ Two holes in 0.12.0's own "tools always return a clean error" fix.
13
+
14
+ ### Fixed
15
+ - **A non-UTF-8 `agents.yaml` no longer crashes every surface.** `UnicodeDecodeError`
16
+ is a `ValueError`, not an `OSError`, so a config saved as cp1251/UTF-16 slipped
17
+ past all three handlers added in 0.12.0 and raised a bare traceback out of every
18
+ MCP tool, `agent-dispatch list` — and `doctor`, the command you run *because*
19
+ the config is broken. The set of load errors is now declared once
20
+ (`config.CONFIG_LOAD_ERRORS`) and shared by all three, and `doctor` reports the
21
+ encoding (and an unreadable file) as a normal FAIL with a fix. The shared set
22
+ lives in `config.CONFIG_LOAD_ERRORS`; the CLI still branches per type because
23
+ each one deserves a different remediation line.
24
+ - **An undecodable byte from the `claude` CLI no longer kills a paid-for
25
+ dispatch.** `text=True` decodes strictly, so one invalid byte on stdout raised
26
+ `UnicodeDecodeError` — a `ValueError`, caught by nothing in the runner — out of
27
+ `dispatch`, `dispatch_stream`, the MCP tools and `agent-dispatch test`. Both
28
+ spawn sites now decode with `errors="replace"`, so a mangled byte becomes
29
+ U+FFFD instead of discarding the run. (Present since the initial commit.)
30
+ - **`dispatch` classifies spawn failures like `dispatch_stream` already did.**
31
+ A directory that passes `is_dir()` but cannot be entered — or that vanishes
32
+ between the check and the spawn — made `subprocess.run` raise straight through
33
+ the `except subprocess.TimeoutExpired`. It now returns `not_found` /
34
+ `permission` / `cli_error` like the streaming path.
35
+ - **The six job tools got the I/O envelope too.** `dispatch_status`, `_wait`,
36
+ `_cancel`, `_jobs`, `fetch_result` and `dispatch_gc` never load config, so they
37
+ carried no guard — an unwritable or misconfigured jobs directory raised out of
38
+ them. The guard is now split (`_io_guard` / `_config_guard`) and both halves
39
+ are applied where each is needed.
40
+ - **`add_agent` reports an unresolvable path** (`~unknown-user`) as an error
41
+ envelope instead of raising `RuntimeError`.
42
+ - **A failed config *write* is reported, not raised.** The same contract had the
43
+ other half missing: a full disk or a read-only volume made `save_config`
44
+ raise OSError straight out of `add_agent`/`update_agent`/`remove_agent` and out
45
+ of the matching CLI commands. Both surfaces now report it — the CLI adds that
46
+ the previous config is intact, which the atomic write guarantees.
47
+
48
+ ### Changed
49
+ - Docs corrected against the code: stale-job recovery now documents the
50
+ `pending` sweep and its 24h threshold; the config-lock guarantee is stated as
51
+ best-effort (it proceeds unlocked after 10s rather than freezing the server);
52
+ `add_agent(timeout=0)` documents that it stores the literal 300, not
53
+ `settings.default_timeout`.
54
+
55
+ ## [0.12.0] - 2026-07-29
56
+
57
+ Reliability pass over the streaming path, config durability, and the tool
58
+ boundary — found by an exhaustive audit of every module.
59
+
60
+ ### Fixed
61
+ - **`mcp` is capped below 2.0.** The dependency was declared as
62
+ `mcp[cli]>=1.2.0` with no upper bound, and `mcp` 2.0 removed
63
+ `mcp.server.fastmcp` (FastMCP became `mcp.server.mcpserver.MCPServer`). Any
64
+ fresh install therefore resolved 2.x and died with
65
+ `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` the moment
66
+ `agent-dispatch serve` started — i.e. **0.11.0 was broken for new installs**.
67
+ Pinned to `>=1.2.0,<2` (verified against 1.29.0); the cap lifts when
68
+ `server.py` is ported to the 2.x API.
69
+ - **A timeout now actually bounds a streaming dispatch.** The timer killed only
70
+ the direct `claude` process. Any grandchild that inherited its stdout — a
71
+ backgrounded dev server, a `run_in_background` Bash call, a watcher — kept
72
+ that pipe from reaching EOF, so the read loop stayed parked for the
73
+ grandchild's entire lifetime: the deadline was ignored, the concurrency slot
74
+ stayed held, and `dispatch_cancel` could not free it. The child is now spawned
75
+ in its own process group and the timeout kills the whole tree (measured on a
76
+ reproduction: 20.4s → 3.0s for a 3s timeout).
77
+ - **Streaming no longer deadlocks on a chatty agent.** `dispatch_stream` opened
78
+ `stderr` as a pipe but only read it *after* the stdout loop finished. Once the
79
+ child wrote more than one pipe buffer (~64 KiB) of stderr — node deprecation
80
+ warnings, MCP server startup noise, a tool dumping a stack trace — it blocked
81
+ in `write(2)`, never emitted its result line, and the dispatch burned its
82
+ entire timeout before coming back as a bogus `error_type: "timeout"`. stderr
83
+ is now drained concurrently by a daemon reader. This affected the
84
+ `dispatch_stream` tool, `agent-dispatch test --stream`, **and every async job**
85
+ (the worker dispatches through the streaming runner). The pipe is never closed
86
+ while that reader is still blocked on it — a stdio MCP server inherits the
87
+ child's `stderr` and can outlive it, and `close()` waits on the reader's buffer
88
+ lock *without a timeout*, which would hang the dispatch outright.
89
+ - **A completed streaming result is no longer thrown away.** `dispatch_stream`
90
+ checked the timeout flag before the received result, so an agent that
91
+ answered — and was billed — but whose process lingered past the deadline came
92
+ back as a failed timeout. The result now wins; when the process had to be
93
+ killed after answering, that is reported as a `hint`, not a failure.
94
+ - **`dispatch(return_ref=True)` now honors `return_ref` on a cache hit.** It
95
+ returned the full cached result text inline instead of a compact ref —
96
+ exactly the context blow-up the caller asked to avoid. (`dispatch_parallel`
97
+ already handled this correctly.)
98
+ - **`agents.yaml` is written atomically.** `save_config` truncated the live file
99
+ and rewrote it in place, so an interrupted write (disk full, quota, SIGKILL)
100
+ left a half-written config that no longer parses — losing every agent and
101
+ group at once. It now writes a temp file and renames, like `JobStore`.
102
+ - **Concurrent config edits no longer lose each other.** Every mutation is a
103
+ load / mutate / save-whole-file cycle, and the CLI and the MCP server edit the
104
+ same file: two overlapping writers each saved their own stale snapshot, so one
105
+ agent silently disappeared. All mutation sites now hold a cross-process
106
+ advisory lock (`flock`, degrading to thread-only where unavailable). The same
107
+ lock now guards job-file transitions, so a CLI `cancel` can no longer be
108
+ overwritten by the server's `finish`. The lock is taken with a bounded wait
109
+ (10s) rather than a blocking `flock`, because the MCP server acquires it on
110
+ its event-loop thread — one wedged holder would otherwise freeze every tool;
111
+ no mutation suspends while holding it (the in-process guard is re-entrant per
112
+ *thread*, so an `await` inside the critical section would let a second
113
+ coroutine walk straight through it).
114
+ - **A symlinked `agents.yaml` keeps its symlink.** The atomic rename would have
115
+ replaced the link itself, silently orphaning the real file in a dotfiles repo;
116
+ the link is resolved before the swap.
117
+ - **A malformed `agents.yaml` no longer crashes every MCP tool.** A YAML syntax
118
+ error or a schema violation escaped as a raw protocol-level exception with no
119
+ remediation — including from the read-only tools an agent would use to
120
+ diagnose it. Tools now return the documented `{"error": ..., "hint": ...}`
121
+ envelope. A non-path `directory:` value raised a bare `TypeError` out of the
122
+ field validator (breaking the CLI too); it is now a normal validation error.
123
+ - **Stale results are no longer served after an agent's config changes.** The
124
+ cache key contains the agent *name*, not its directory, permission set or
125
+ model — so the documented remove-and-re-add flow kept serving the previous
126
+ project's answers for the rest of the TTL. `add_agent` / `update_agent` /
127
+ `remove_agent` now invalidate that agent's cached results.
128
+ - **Successful-but-degraded results are no longer cached.** A dispatch that
129
+ answered with `denied_tools` (or over budget) was cached for the full TTL,
130
+ which made the documented "grant access, then re-dispatch" recovery a no-op.
131
+ - **`agent-dispatch gc --days 0` (or negative) no longer silently purges every
132
+ terminal job** — including `return_ref` results a caller was about to fetch.
133
+ It is rejected, matching the `dispatch_gc` MCP tool; use the new `--all` flag
134
+ to purge everything on purpose.
135
+ - **The budget hints now name a flag that exists.** Both the runner's
136
+ `error_type: "budget"` hint and `agent-dispatch test` told the user to run
137
+ `agent-dispatch update <name> --max-budget-usd`, but the option was
138
+ `--max-budget`, so the copy-pasted command failed. `--max-budget-usd` is now
139
+ an accepted alias on `add` and `update`.
140
+ - **A negative timeout can no longer brick an agent.** `-5` was written straight
141
+ to `agents.yaml`, reached `subprocess.run(timeout=-5)`, and made every
142
+ dispatch fail instantly with a nonsensical "timed out after -5s". Rejected now
143
+ at the model, the MCP tool, and the CLI; negative budgets likewise, on both
144
+ `add` and `update` (they put a token starting with `-` on the `claude` command
145
+ line, and pydantic does not validate on assignment — the boundary checks are
146
+ what actually guard the mutation paths).
147
+ - **Jobs stuck in `pending` are recovered.** `recover_stale` only swept
148
+ `running`, and `gc` only deletes terminal jobs — so a job whose worker never
149
+ started (server killed while it was queued) stayed pending forever: an eternal
150
+ poll target and permanent disk growth. Pending uses a far longer threshold
151
+ (24×) than running: the jobs directory is shared by every `agent-dispatch
152
+ serve`, and an hours-old pending job may still be queued in another live
153
+ server.
154
+ - **`dispatch` survives an unexpected CLI payload.** A non-object JSON body, or
155
+ a `result` field that is not a string, raised out of the runner instead of
156
+ returning a `DispatchResult`. Exit code 0 with no output at all is now a
157
+ failure rather than a cached empty success.
158
+ - **`dispatch_parallel` returns an error envelope for a non-string `agent` or
159
+ `task`** instead of raising `TypeError`, and the aggregator now receives a
160
+ `return_ref` item's summary (labelled as a preview) instead of an empty body.
161
+ - The prompt is no longer scanned when locating flags: a task whose text is
162
+ exactly `--output-format` or `--session-id` rewrote the prompt instead of the
163
+ flag. The cache also hands back a copy, so a caller mutating a result cannot
164
+ corrupt the entry for everyone else.
165
+
166
+ ### Added
167
+ - `agent-dispatch gc --all` — purge every terminal job regardless of age.
168
+ - `--max-budget-usd` as an alias for `--max-budget` on `add` and `update`.
169
+ - `DispatchCache.invalidate_agent(name)` and `config.ProcessLock` /
170
+ `config.config_lock()`.
171
+ - 66 tests (561 total), including real-subprocess regression tests for the
172
+ stderr deadlock, the inherited-pipe hang and the process-tree timeout — a
173
+ mocked `Popen` structurally cannot reproduce any of them.
174
+
10
175
  ## [0.11.0] - 2026-07-27
11
176
 
12
177
  The spend cap was already real — now the result says so.
@@ -426,7 +591,9 @@ cache bounding, and stale-job recovery.
426
591
  - Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
427
592
  commit SHAs for supply-chain integrity.
428
593
 
429
- [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.11.0...HEAD
594
+ [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.12.1...HEAD
595
+ [0.12.1]: https://github.com/ginkida/agent-dispatch/compare/v0.12.0...v0.12.1
596
+ [0.12.0]: https://github.com/ginkida/agent-dispatch/compare/v0.11.0...v0.12.0
430
597
  [0.11.0]: https://github.com/ginkida/agent-dispatch/compare/v0.10.0...v0.11.0
431
598
  [0.10.0]: https://github.com/ginkida/agent-dispatch/compare/v0.9.0...v0.10.0
432
599
  [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.1
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
 
@@ -388,11 +388,13 @@ Register a new project directory as an agent. Description is auto-generated from
388
388
  | `name` | string | yes | Agent name (letters, digits, hyphens, underscores) |
389
389
  | `directory` | string | yes | Path to an existing project directory (`~` is expanded, relative paths resolved) |
390
390
  | `description` | string | no | What this agent can do — auto-generated if empty |
391
- | `timeout` | int | no | Timeout in seconds (0 = use global default) |
392
- | `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = no limit) |
391
+ | `timeout` | int | no | Timeout in seconds (0 = 300; this is a literal default, not `settings.default_timeout`) |
392
+ | `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = inherit `settings.default_max_budget_usd`; no cap only when that is unset too) |
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
 
@@ -403,11 +405,15 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
403
405
  | `name` | string | yes | Agent name to update |
404
406
  | `description` | string | no | New description |
405
407
  | `timeout` | int | no | New timeout (0 = don't change) |
406
- | `max_budget_usd` | float | no | New budget limit (0 = don't change, negative = clear the limit) |
408
+ | `max_budget_usd` | float | no | New budget limit (0 = don't change; negative clears the *per-agent* cap, after which `settings.default_max_budget_usd` applies) |
407
409
  | `model` | string | no | Model override. `"none"` to clear |
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
 
@@ -468,7 +474,7 @@ Async workers run with streaming under the hood: the job file keeps a rolling ta
468
474
 
469
475
  `dispatch_jobs(status?)` lists recent jobs as summaries (filter by `pending` / `running` / `done` / `failed` / `cancelled`). `dispatch_gc(max_age_days=7)` purges terminal jobs older than the threshold — pending and running jobs are never deleted.
470
476
 
471
- Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server marks jobs left in `running` by a crashed instance as `failed` once they are stale (stuck for over an hour).
477
+ Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server recovers jobs a crashed instance abandoned: `running` ones stuck over an hour, and `pending` ones over 24 hours, are marked `failed` so they stop being polled forever and become collectable by `dispatch_gc`. (The `pending` threshold is deliberately long — the jobs directory is shared by every running server, so a job queued behind another server's concurrency limit must not be swept.)
472
478
 
473
479
  | When to use async | When to use `dispatch` |
474
480
  |-------------------|------------------------|
@@ -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), so an interrupted write can never truncate it. Every mutation path (CLI and MCP server alike) also takes a cross-process advisory lock, so concurrent edits don't drop one another's agents. The lock is best-effort by design: after waiting 10 seconds it logs a warning and proceeds anyway, because a wedged lock holder must not freeze the MCP server — so on a heavily contended config a lost update is possible, while a truncated one is not.
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
 
@@ -358,11 +358,13 @@ Register a new project directory as an agent. Description is auto-generated from
358
358
  | `name` | string | yes | Agent name (letters, digits, hyphens, underscores) |
359
359
  | `directory` | string | yes | Path to an existing project directory (`~` is expanded, relative paths resolved) |
360
360
  | `description` | string | no | What this agent can do — auto-generated if empty |
361
- | `timeout` | int | no | Timeout in seconds (0 = use global default) |
362
- | `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = no limit) |
361
+ | `timeout` | int | no | Timeout in seconds (0 = 300; this is a literal default, not `settings.default_timeout`) |
362
+ | `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = inherit `settings.default_max_budget_usd`; no cap only when that is unset too) |
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
 
@@ -373,11 +375,15 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
373
375
  | `name` | string | yes | Agent name to update |
374
376
  | `description` | string | no | New description |
375
377
  | `timeout` | int | no | New timeout (0 = don't change) |
376
- | `max_budget_usd` | float | no | New budget limit (0 = don't change, negative = clear the limit) |
378
+ | `max_budget_usd` | float | no | New budget limit (0 = don't change; negative clears the *per-agent* cap, after which `settings.default_max_budget_usd` applies) |
377
379
  | `model` | string | no | Model override. `"none"` to clear |
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
 
@@ -438,7 +444,7 @@ Async workers run with streaming under the hood: the job file keeps a rolling ta
438
444
 
439
445
  `dispatch_jobs(status?)` lists recent jobs as summaries (filter by `pending` / `running` / `done` / `failed` / `cancelled`). `dispatch_gc(max_age_days=7)` purges terminal jobs older than the threshold — pending and running jobs are never deleted.
440
446
 
441
- Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server marks jobs left in `running` by a crashed instance as `failed` once they are stale (stuck for over an hour).
447
+ Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server recovers jobs a crashed instance abandoned: `running` ones stuck over an hour, and `pending` ones over 24 hours, are marked `failed` so they stop being polled forever and become collectable by `dispatch_gc`. (The `pending` threshold is deliberately long — the jobs directory is shared by every running server, so a job queued behind another server's concurrency limit must not be swept.)
442
448
 
443
449
  | When to use async | When to use `dispatch` |
444
450
  |-------------------|------------------------|
@@ -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), so an interrupted write can never truncate it. Every mutation path (CLI and MCP server alike) also takes a cross-process advisory lock, so concurrent edits don't drop one another's agents. The lock is best-effort by design: after waiting 10 seconds it logs a warning and proceeds anyway, because a wedged lock holder must not freeze the MCP server — so on a heavily contended config a lost update is possible, while a truncated one is not.
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.1"
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.1"
@@ -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)