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.
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/.github/workflows/ci.yml +2 -2
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/.github/workflows/publish.yml +2 -2
- agent_dispatch-0.12.0/AGENTS.md +79 -0
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/CHANGELOG.md +163 -1
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/PKG-INFO +18 -10
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/README.md +16 -8
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/agents.example.yaml +2 -1
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/pyproject.toml +6 -2
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/cache.py +33 -5
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/cli.py +211 -87
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/config.py +150 -5
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/jobs.py +61 -21
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/models.py +21 -6
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/runner.py +310 -78
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/src/agent_dispatch/server.py +230 -40
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_cache.py +82 -13
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_cli.py +144 -0
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_config.py +146 -1
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_jobs.py +137 -9
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_models.py +41 -0
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_runner.py +732 -152
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/test_server.py +313 -0
- agent_dispatch-0.10.0/AGENTS.md +0 -58
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/.gitignore +0 -0
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/LICENSE +0 -0
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/SECURITY.md +0 -0
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/assets/mascot.png +0 -0
- {agent_dispatch-0.10.0 → agent_dispatch-0.12.0}/tests/__init__.py +0 -0
- {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@
|
|
21
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
22
22
|
|
|
23
|
-
- uses: actions/setup-python@
|
|
23
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
24
24
|
with:
|
|
25
25
|
python-version: ${{ matrix.python-version }}
|
|
26
26
|
|
|
@@ -15,9 +15,9 @@ jobs:
|
|
|
15
15
|
id-token: write # OIDC token for PyPI Trusted Publisher
|
|
16
16
|
contents: read # checkout the tagged source
|
|
17
17
|
steps:
|
|
18
|
-
- uses: actions/checkout@
|
|
18
|
+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
19
19
|
|
|
20
|
-
- uses: actions/setup-python@
|
|
20
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
21
21
|
with:
|
|
22
22
|
python-version: "3.12"
|
|
23
23
|
|
|
@@ -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.
|
|
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.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: MCP server that lets Claude Code agents delegate tasks to agents in other project directories
|
|
5
5
|
Project-URL: Homepage, https://github.com/ginkida/agent-dispatch
|
|
6
6
|
Project-URL: Repository, https://github.com/ginkida/agent-dispatch
|
|
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Libraries
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
22
|
Requires-Dist: click>=8.0
|
|
23
|
-
Requires-Dist: mcp[cli]
|
|
23
|
+
Requires-Dist: mcp[cli]<2,>=1.2.0
|
|
24
24
|
Requires-Dist: pyyaml>=6.0
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
@@ -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`
|
|
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
|
|
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).
|
|
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`
|
|
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
|
|
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).
|
|
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 #
|
|
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.
|
|
3
|
+
version = "0.12.0"
|
|
4
4
|
description = "MCP server that lets Claude Code agents delegate tasks to agents in other project directories"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -32,7 +32,11 @@ classifiers = [
|
|
|
32
32
|
"Topic :: Software Development :: Libraries",
|
|
33
33
|
]
|
|
34
34
|
dependencies = [
|
|
35
|
-
|
|
35
|
+
# Upper bound is load-bearing: mcp 2.0 removed `mcp.server.fastmcp` (FastMCP
|
|
36
|
+
# was replaced by `mcp.server.mcpserver.MCPServer`), which server.py imports.
|
|
37
|
+
# Without the cap a fresh install resolves 2.x and `agent-dispatch serve`
|
|
38
|
+
# dies with ModuleNotFoundError. Lift it only together with the port.
|
|
39
|
+
"mcp[cli]>=1.2.0,<2",
|
|
36
40
|
"pyyaml>=6.0",
|
|
37
41
|
"click>=8.0",
|
|
38
42
|
]
|
|
@@ -26,7 +26,10 @@ class DispatchCache:
|
|
|
26
26
|
def __init__(self, ttl: int = 300, max_size: int = 1000) -> None:
|
|
27
27
|
self._ttl = ttl
|
|
28
28
|
self._max_size = max_size
|
|
29
|
-
|
|
29
|
+
# value = (stored_at, agent_name, result). The agent name is kept
|
|
30
|
+
# alongside the hashed key so invalidate_agent() can drop exactly one
|
|
31
|
+
# agent's entries when its config changes.
|
|
32
|
+
self._store: dict[str, tuple[float, str, DispatchResult]] = {}
|
|
30
33
|
self._lock = threading.Lock()
|
|
31
34
|
self._hits = 0
|
|
32
35
|
self._misses = 0
|
|
@@ -69,13 +72,16 @@ class DispatchCache:
|
|
|
69
72
|
if entry is None:
|
|
70
73
|
self._misses += 1
|
|
71
74
|
return None
|
|
72
|
-
ts, result = entry
|
|
75
|
+
ts, _agent, result = entry
|
|
73
76
|
if time.monotonic() - ts > self._ttl:
|
|
74
77
|
del self._store[key]
|
|
75
78
|
self._misses += 1
|
|
76
79
|
return None
|
|
77
80
|
self._hits += 1
|
|
78
|
-
|
|
81
|
+
# Hand back a copy: the stored result is shared by every caller of
|
|
82
|
+
# this key, and a caller that mutates what it got (adding a flag,
|
|
83
|
+
# truncating text) would silently corrupt the entry for everyone.
|
|
84
|
+
return result.model_copy(deep=True)
|
|
79
85
|
|
|
80
86
|
def put(
|
|
81
87
|
self,
|
|
@@ -89,6 +95,13 @@ class DispatchCache:
|
|
|
89
95
|
) -> None:
|
|
90
96
|
if not result.success:
|
|
91
97
|
return # don't cache failures
|
|
98
|
+
if result.denied_tools or result.budget_exceeded:
|
|
99
|
+
# Successful but degraded: the agent answered with tools blocked, or
|
|
100
|
+
# the run cost more than its cap. The documented recovery is "grant
|
|
101
|
+
# access / raise the budget, then re-dispatch" — caching this would
|
|
102
|
+
# serve the same crippled answer back for the whole TTL and make that
|
|
103
|
+
# recovery a no-op (the permission config is not part of the key).
|
|
104
|
+
return
|
|
92
105
|
key = self._make_key(agent, task, context, caller, goal, response_format)
|
|
93
106
|
with self._lock:
|
|
94
107
|
# Bound memory: when at capacity and inserting a new key, evict the
|
|
@@ -100,7 +113,22 @@ class DispatchCache:
|
|
|
100
113
|
oldest = min(self._store, key=lambda k: self._store[k][0])
|
|
101
114
|
del self._store[oldest]
|
|
102
115
|
self._evictions += 1
|
|
103
|
-
self._store[key] = (time.monotonic(), result)
|
|
116
|
+
self._store[key] = (time.monotonic(), agent, result)
|
|
117
|
+
|
|
118
|
+
def invalidate_agent(self, agent: str) -> int:
|
|
119
|
+
"""Drop every cached result for *agent*. Returns the number removed.
|
|
120
|
+
|
|
121
|
+
Called whenever an agent's config changes: the cache key is
|
|
122
|
+
(agent, task, context, caller, goal, response_format), so it cannot tell
|
|
123
|
+
that the name now points at a different directory, permission set or
|
|
124
|
+
model. Without this, ``remove_agent`` + ``add_agent`` under the same name
|
|
125
|
+
keeps serving the previous project's answers for the rest of the TTL.
|
|
126
|
+
"""
|
|
127
|
+
with self._lock:
|
|
128
|
+
stale = [k for k, (_ts, name, _r) in self._store.items() if name == agent]
|
|
129
|
+
for k in stale:
|
|
130
|
+
del self._store[k]
|
|
131
|
+
return len(stale)
|
|
104
132
|
|
|
105
133
|
def clear(self) -> int:
|
|
106
134
|
with self._lock:
|
|
@@ -114,7 +142,7 @@ class DispatchCache:
|
|
|
114
142
|
def evict_expired(self) -> int:
|
|
115
143
|
now = time.monotonic()
|
|
116
144
|
with self._lock:
|
|
117
|
-
expired = [k for k, (ts,
|
|
145
|
+
expired = [k for k, (ts, _n, _r) in self._store.items() if now - ts > self._ttl]
|
|
118
146
|
for k in expired:
|
|
119
147
|
del self._store[k]
|
|
120
148
|
return len(expired)
|