agent-dispatch 0.12.1__tar.gz → 0.14.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.12.1 → agent_dispatch-0.14.0}/.github/workflows/publish.yml +8 -1
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/AGENTS.md +15 -4
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/CHANGELOG.md +132 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/PKG-INFO +66 -7
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/README.md +64 -5
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/agents.example.yaml +19 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/pyproject.toml +1 -1
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/cache.py +6 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/cli.py +77 -2
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/config.py +31 -2
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/jobs.py +40 -8
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/models.py +27 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/runner.py +201 -42
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/server.py +279 -137
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_cache.py +15 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_cli.py +158 -18
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_config.py +73 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_jobs.py +33 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_models.py +12 -3
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_runner.py +202 -4
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_server.py +381 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/.github/workflows/ci.yml +0 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/.gitignore +0 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/LICENSE +0 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/SECURITY.md +0 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/assets/mascot.png +0 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/__init__.py +0 -0
- {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/conftest.py +0 -0
|
@@ -26,5 +26,12 @@ jobs:
|
|
|
26
26
|
pip install build
|
|
27
27
|
python -m build
|
|
28
28
|
|
|
29
|
+
# Keep this current with the build backend. The pinned action bundles its
|
|
30
|
+
# own twine, and twine rejects a Metadata-Version it does not know: the
|
|
31
|
+
# v0.13.0 publish failed because hatchling 1.32 emits `Metadata-Version:
|
|
32
|
+
# 2.5` while the previous pin (Feb 2026) shipped twine 6.1.0 / packaging
|
|
33
|
+
# 25.0. `python -m build` resolves the newest hatchling at build time, so
|
|
34
|
+
# this drifts on its own with no repo change — see the release checklist's
|
|
35
|
+
# `twine check` step, which catches it before a tag is cut.
|
|
29
36
|
- name: Publish to PyPI
|
|
30
|
-
uses: pypa/gh-action-pypi-publish@
|
|
37
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
@@ -28,7 +28,7 @@ pip install -e ".[dev]"
|
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
ruff check src/ tests/
|
|
31
|
-
python3 -m pytest tests/ -v #
|
|
31
|
+
python3 -m pytest tests/ -v # 637 tests, ~15s
|
|
32
32
|
```
|
|
33
33
|
|
|
34
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.
|
|
@@ -52,16 +52,27 @@ Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.whi
|
|
|
52
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
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
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.
|
|
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. For a *running* job, `started_at` alone does **not** prove abandonment — a dispatch may legitimately run to the 7200s timeout ceiling, so the file's own mtime is checked too (a live worker rewrites it on every progress flush). That check can only ever *skip* a recovery, never add one.
|
|
56
|
+
- Deleting job records is **opt-in** (`settings.job_retention_days`, default `0` = off) and only ever happens at server start, never inside a tool. They are the user's dispatch history and the deletion is irreversible, so an unreadable config is treated as "do nothing" rather than falling back to a default retention.
|
|
57
|
+
- `max_concurrency` must bound *subprocesses*, not coroutines. Cancelling a coroutine does not stop the thread behind `asyncio.to_thread`, so `async with sem:` gave the slot away while `claude` kept running and billing. Dispatches go through `_dispatch_guarded`, which releases from the future's done-callback and `shield`s the await. Never "simplify" it back to `async with`.
|
|
58
|
+
- Tool responses are serialized through `server._dumps`, never bare `json.dumps`: the stdlib default (`ensure_ascii=True`) turns every non-ASCII character into a `\uXXXX` escape, tripling the bytes and tokenizing badly, for no gain — the stdio transport emits raw UTF-8 via pydantic anyway. A real `list_groups()` carried 8520 escapes and weighed 59 KB instead of 25 KB.
|
|
59
|
+
- `agents.yaml` is parsed with libyaml's `CSafeLoader` when available (`config._YamlLoader`), falling back to the pure-Python **safe** loader — never `yaml.Loader`. The config is re-read on every tool call, so this parse is on the hot path of all 21 tools *and* blocks the event-loop thread: 9.80 ms → 0.76 ms on a real 38 KB config.
|
|
56
60
|
- 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
61
|
- 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
62
|
- 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,
|
|
63
|
+
- Only *clean* successes are cached: `cache.put` refuses failures, `denied_tools` results, `budget_exceeded` results, and results whose `outcome` is `partial`/`blocked`, so the documented "grant access / fix the cause, then re-dispatch" recovery is never short-circuited.
|
|
64
|
+
- **The dispatch protocol goes in the system prompt, not the task.** `_build_system_prompt` (runner.py) renders the protocol (non-interactive, time/spend budget, lead with the outcome, trailing `STATUS:` line) plus the agent's `instructions`, and `_build_command` passes it as `--append-system-prompt` — on `--resume` too, since the CLI re-applies an appended prompt on every launch. `_build_prompt` (the `-p` text) is unchanged, so the cache key is unchanged. The text always starts with a `##` header line so it can never be read as a flag. `settings.dispatch_protocol: false` turns the protocol off (for CLIs that predate the flag; `doctor` probes `claude --help` for it); per-agent `instructions` still go through when set.
|
|
65
|
+
- **`outcome` is lifted from the LAST line of the result** (`_split_outcome`), before JSON parsing, by the shared `_build_success_result` (the success-side twin of `_build_error_result` — both `dispatch` and `dispatch_stream` go through it) and by the plain-text fallback tier. A bare marker line is removed from `result`; a marker with a trailing reason stays. `outcome` never flips `success`; `partial`/`blocked` add a `hint` with the `dispatch_session(...)` continuation, after the denial hint. In JSON mode the protocol omits the STATUS instruction (the JSON footer governs), but a STATUS line that arrives anyway is still stripped so `parsed_result` survives.
|
|
60
66
|
- 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.
|
|
67
|
+
- The config error sets are declared **once** and in two halves: `config.CONFIG_LOAD_ERRORS` (read) and `config.CONFIG_SAVE_ERRORS` (write — `yaml.dump`'s `RepresenterError` is a `yaml.YAMLError`, therefore neither `OSError` nor `ConfigLoadError`, and used to escape both the MCP guard and the CLI's `_save_or_exit`). Two halves, not one set, because the remediations differ: a failed write is atomic so the old config survives, while a failed read needs the YAML fixed.
|
|
61
68
|
- 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
69
|
|
|
63
70
|
- 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
71
|
|
|
72
|
+
- `mcp` is pinned **`>=1.2.0,<2`** deliberately: 2.0 removed `mcp.server.fastmcp`, which `server.py` imports, so an unbounded range gives every fresh install a dead `agent-dispatch serve`. Lifting the cap means porting to `mcp.server.mcpserver.MCPServer` — it is not a dependency bump.
|
|
73
|
+
- Verify packaging in a **clean venv**, never the dev machine: build the wheel, install it fresh, import the server. A stale pin in local site-packages hides exactly the failure a new user hits first.
|
|
74
|
+
- Run **`twine check dist/*`** before cutting the tag, with a *current* twine. The clean-venv check does not cover this: it proves the wheel installs, not that PyPI's uploader will accept its metadata. `python -m build` pulls the newest hatchling at build time, so the emitted `Metadata-Version` climbs on its own, and `pypa/gh-action-pypi-publish` is pinned to a SHA that bundles a fixed twine — v0.13.0's publish failed on exactly that mismatch (hatchling 1.32 → metadata 2.5; pinned twine 6.1.0 → "not a valid metadata version"). When it happens, bump the action pin rather than pinning the backend down.
|
|
75
|
+
|
|
65
76
|
## Deliberately not built
|
|
66
77
|
|
|
67
78
|
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.
|
|
@@ -76,4 +87,4 @@ Python ≥ 3.10 · `from __future__ import annotations` everywhere · Pydantic v
|
|
|
76
87
|
|
|
77
88
|
## More detail
|
|
78
89
|
|
|
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/`,
|
|
90
|
+
[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/`, 637 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,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.0] - 2026-09-08
|
|
11
|
+
|
|
12
|
+
The delegation round: what a dispatched agent is *told*, and what it tells
|
|
13
|
+
back. Until now `claude -p` was launched with the task and nothing else — it
|
|
14
|
+
did not know it was being driven by another agent, that nobody would answer a
|
|
15
|
+
question, how long it had, or how to report an unfinished job. The failure
|
|
16
|
+
mode was concrete and billed: an ambiguous task ended in *"Could you clarify
|
|
17
|
+
which service you mean?"*, `success: true`, cached for the whole TTL.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **The dispatch protocol.** Every dispatch now appends a short system prompt
|
|
21
|
+
(`--append-system-prompt`, never mixed into the task text) telling the agent
|
|
22
|
+
that it is non-interactive and dispatched by `caller`, that it must state
|
|
23
|
+
assumptions instead of asking, that a denied tool is something to report and
|
|
24
|
+
work around rather than stop on, what its time budget (and spend cap) is, to
|
|
25
|
+
lead with the outcome — which is what makes a `return_ref` summary, the
|
|
26
|
+
*head* of the text, worth reading — and to end with one line:
|
|
27
|
+
`STATUS: done | partial | blocked`. Passed on `--resume` too. Verified live
|
|
28
|
+
against claude 2.1.263 (`agent-dispatch test <agent> --stream`).
|
|
29
|
+
- **`DispatchResult.outcome`** — that trailing line, lifted out of `result`
|
|
30
|
+
into a field: `done`, `partial` or `blocked`, or absent when the agent did
|
|
31
|
+
not report one. It never flips `success`. `partial`/`blocked` come with a
|
|
32
|
+
`hint` carrying the exact `dispatch_session(..., session_id=...)` call to
|
|
33
|
+
continue, ride the `return_ref` payload and `dispatch_jobs` summaries, are
|
|
34
|
+
shown by `agent-dispatch test` / `job <id>`, are labelled for the
|
|
35
|
+
`dispatch_parallel` aggregator so a blocked member is not synthesized as a
|
|
36
|
+
finished one — and are **not cached**: whatever the agent was missing is not
|
|
37
|
+
in the cache key, so a retry after fixing it must run fresh.
|
|
38
|
+
- **Per-agent `instructions`** — standing orders appended after the protocol
|
|
39
|
+
on every dispatch ("read-only SQL only", "never restart a stack unless the
|
|
40
|
+
task says so"). `add_agent`/`update_agent` (`"none"` clears), CLI `add` /
|
|
41
|
+
`update --instructions` (`none` clears), shown by `inspect_agent` and
|
|
42
|
+
`describe`, pruned from YAML when empty. Changing them invalidates the
|
|
43
|
+
agent's cache entries like any other config change.
|
|
44
|
+
- **`settings.dispatch_protocol`** (default `true`) — set to `false` for a raw
|
|
45
|
+
`claude -p` (a CLI that predates `--append-system-prompt`, or an A/B). The
|
|
46
|
+
protocol and the instructions share one flag, so instructions still go
|
|
47
|
+
through when set. `doctor` now probes `claude --help` for the flag and warns
|
|
48
|
+
with the exact remediation when it is missing.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
- In `response_format="json"` mode the protocol omits the "lead with the
|
|
52
|
+
outcome" and STATUS bullets — the JSON footer governs the reply shape. A
|
|
53
|
+
STATUS line that arrives anyway is still stripped before parsing, so
|
|
54
|
+
`parsed_result` survives it.
|
|
55
|
+
- `dispatch` and `dispatch_stream` build their success result through one
|
|
56
|
+
`_build_success_result`, the twin of `_build_error_result`.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## [0.13.0] - 2026-08-13
|
|
60
|
+
|
|
61
|
+
An efficiency round, measured against a real 38 KB config (4 agents, 6 groups),
|
|
62
|
+
a 294-record jobs directory and 14 concurrently running servers — not against
|
|
63
|
+
synthetic fixtures. Findings came from a five-axis audit (caller context,
|
|
64
|
+
latency/IO, spend, CPU, concurrency) whose every claim was re-measured
|
|
65
|
+
adversarially before implementation; most were confirmed as real but too small
|
|
66
|
+
to matter next to a 10–120 s subprocess and were deliberately left alone.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
- **Tool responses no longer escape non-ASCII, cutting discovery payloads
|
|
70
|
+
roughly in half.** `json.dumps` defaults to `ensure_ascii=True`, so every
|
|
71
|
+
Cyrillic character left as a `\uXXXX` escape — 6 bytes for what UTF-8 stores
|
|
72
|
+
in 2, and a far worse token sequence. One `list_groups()` carried **8520**
|
|
73
|
+
such escapes: 59 KB where 25 KB was enough. Measured over a full discovery
|
|
74
|
+
pass (`list_agents` + `list_groups` + `inspect_group` + `inspect_agent`):
|
|
75
|
+
104 KB → 51 KB, about **13 000 tokens saved per pass**, on every call, in
|
|
76
|
+
every session. Nothing is lost: the stdio transport serializes the JSON-RPC
|
|
77
|
+
envelope with pydantic's `model_dump_json`, which already emits raw UTF-8 —
|
|
78
|
+
which is also why the `dispatch` family never had this problem while every
|
|
79
|
+
`json.dumps` tool did. All 57 call sites now go through one `_dumps` helper
|
|
80
|
+
so the two agree. `_dumps` probes encodability and falls back to the escaped
|
|
81
|
+
form for the one payload that needs it — a **lone surrogate**, which an agent
|
|
82
|
+
produces by printing a literal `\udXXX` escape in its JSON output (`json.loads`
|
|
83
|
+
manufactures it from perfectly ASCII input, so the subprocess-level
|
|
84
|
+
`errors="replace"` cannot help). Unescaped it would raise `UnicodeEncodeError`
|
|
85
|
+
inside the stdio transport *after* the tool returned — past every guard.
|
|
86
|
+
The saving is proportional to how much non-ASCII your config and results
|
|
87
|
+
carry: on an all-ASCII setup it is exactly zero.
|
|
88
|
+
- **`agents.yaml` parses ~13x faster.** The config is re-read on every single
|
|
89
|
+
tool call (deliberately — that is how a new agent is picked up without a
|
|
90
|
+
restart), so the parse sits on the hot path of all 21 tools *and* runs on the
|
|
91
|
+
event-loop thread, where it blocks every other tool. Switching to libyaml's
|
|
92
|
+
`CSafeLoader` takes the real config from **9.80 ms to 0.76 ms** per call.
|
|
93
|
+
Falls back to the pure-Python safe loader when PyYAML was built without
|
|
94
|
+
libyaml; both are the *safe* loader, so a config still cannot construct
|
|
95
|
+
arbitrary objects.
|
|
96
|
+
|
|
97
|
+
### Added
|
|
98
|
+
- **`settings.job_retention_days`** — when > 0, terminal job records older than
|
|
99
|
+
N days are deleted at server start. Every async dispatch and every
|
|
100
|
+
`return_ref` dispatch leaves a job file that nothing removed on its own, so
|
|
101
|
+
the directory grew forever while `dispatch_jobs` and stale-job recovery
|
|
102
|
+
parsed every file in it. **Defaults to `0` (off)**: those records are the
|
|
103
|
+
user's own dispatch history and deleting them is irreversible, so it is an
|
|
104
|
+
explicit opt-in rather than something a version bump starts doing to an
|
|
105
|
+
existing install. An unreadable config is treated as `0`, never guessed.
|
|
106
|
+
|
|
107
|
+
### Fixed
|
|
108
|
+
- **`max_concurrency` bounds live subprocesses again, not live coroutines.**
|
|
109
|
+
`async with sem:` released the slot when the *coroutine* unwound, but
|
|
110
|
+
cancelling a coroutine does not stop the thread behind `asyncio.to_thread` —
|
|
111
|
+
so an interrupted turn or a closed session handed the slot to the next
|
|
112
|
+
dispatch while the `claude` subprocess kept running and kept being billed. N
|
|
113
|
+
cancellations meant up to N extra concurrent subprocesses, and each abandoned
|
|
114
|
+
worker also held a thread out of the default executor (`min(32, cpu_count+4)`,
|
|
115
|
+
which `to_thread` needs for *every* dispatch) for the rest of its timeout —
|
|
116
|
+
up to 7200s. The slot is now tied to the worker's real lifetime via
|
|
117
|
+
`_dispatch_guarded`; cancellation still reaches the caller immediately.
|
|
118
|
+
- **A legitimately long dispatch is no longer flipped to `failed` by an
|
|
119
|
+
unrelated server starting up.** `recover_stale` judged abandonment from
|
|
120
|
+
`started_at` alone, but a dispatch may legitimately run up to the 7200s
|
|
121
|
+
timeout ceiling — so any of the (routinely 14+) other servers booting would
|
|
122
|
+
mark a live 2-hour job `failed`, after which `finish()` refuses the real,
|
|
123
|
+
already-paid-for result. A *running* job whose file was modified within the
|
|
124
|
+
threshold is now left alone: a live worker rewrites it on every progress
|
|
125
|
+
flush, so only the file's own age proves abandonment. This can only ever skip
|
|
126
|
+
a recovery — a genuinely abandoned job is picked up on a later start.
|
|
127
|
+
- **A config that cannot be *rendered* now returns an error envelope instead of
|
|
128
|
+
a traceback.** `_get_config` wraps read-side YAML failures into
|
|
129
|
+
`ConfigLoadError`, but `save_config` → `yaml.dump` raises `RepresenterError` —
|
|
130
|
+
a `yaml.YAMLError`, so neither an `OSError` nor a `ConfigLoadError`. It
|
|
131
|
+
therefore slipped past *both* arms of the MCP guard on
|
|
132
|
+
`add_agent`/`update_agent`/`remove_agent` **and** past the CLI's
|
|
133
|
+
`_save_or_exit`. Unreachable today (only JSON-native types are ever dumped)
|
|
134
|
+
and closed now because it is the same "an exception type escapes the handler
|
|
135
|
+
meant to catch it" class that produced the 0.12.0 and 0.12.1 rounds. The set
|
|
136
|
+
is declared once as `config.CONFIG_SAVE_ERRORS`, next to its read-side twin,
|
|
137
|
+
so the two surfaces cannot drift.
|
|
138
|
+
- **Stale-job recovery scans the jobs directory once instead of twice.** It
|
|
139
|
+
called `list()` per status, and `list()` reads and parses every file in the
|
|
140
|
+
directory (~85 ms at 294 records) — at the start of every server process.
|
|
141
|
+
|
|
10
142
|
## [0.12.1] - 2026-07-30
|
|
11
143
|
|
|
12
144
|
Two holes in 0.12.0's own "tools always return a clean error" fix.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-dispatch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -220,7 +220,8 @@ dispatch(
|
|
|
220
220
|
"session_id": "sess-abc-123",
|
|
221
221
|
"cost_usd": 0.02,
|
|
222
222
|
"duration_ms": 5000,
|
|
223
|
-
"num_turns": 2
|
|
223
|
+
"num_turns": 2,
|
|
224
|
+
"outcome": "done"
|
|
224
225
|
}
|
|
225
226
|
|
|
226
227
|
// Response (failure — error_type helps you handle programmatically)
|
|
@@ -278,6 +279,30 @@ Error: TypeError at scheduler.py:42
|
|
|
278
279
|
Check container logs for recent errors related to the scheduler service
|
|
279
280
|
```
|
|
280
281
|
|
|
282
|
+
**The dispatch protocol and `outcome`.** `claude -p` on its own does not know it is being driven by another agent: given an ambiguous task it will happily end with *"Could you clarify which service you mean?"* — a billed run that answered nothing, and one that a plain cache would then serve again for the whole TTL. So every dispatch also appends a short **dispatch protocol** to the agent's system prompt (`--append-system-prompt`, never mixed into your task text):
|
|
283
|
+
|
|
284
|
+
- it is running non-interactively, dispatched by `caller`, and nobody will answer a question or approve an action — state the assumption and proceed, take the safer reading when two differ;
|
|
285
|
+
- a denied tool or a missing thing is something to report, not something to stop on — finish everything else that is possible;
|
|
286
|
+
- its time budget is about the agent's timeout (and its spend cap, if one is set) — scope the work to fit; a complete partial answer beats an unfinished perfect one;
|
|
287
|
+
- lead with the outcome, then the evidence; list what could not be done and why (this is what makes a `return_ref` summary — the **head** of the text — worth reading);
|
|
288
|
+
- end with one line, `STATUS: done`, `STATUS: partial` or `STATUS: blocked`.
|
|
289
|
+
|
|
290
|
+
That last line is lifted out of `result` into **`outcome`** — the agent's own verdict, deterministic to check: `"done"` means complete; `"partial"` / `"blocked"` mean the agent says the work is unfinished, the result names what is missing, and a `hint` spells out the continuation (usually `dispatch_session(..., session_id=...)`). Read `outcome` before reading the text. It never flips `success`, `partial` / `blocked` results are **not cached**, and a `dispatch_parallel(..., aggregate=...)` labels such members for the aggregator so a blocked report is not synthesized as a finished one. Absent when the agent did not report one — the protocol is off (`settings.dispatch_protocol: false`), `response_format="json"` was requested (the JSON footer governs the reply shape there), or the agent simply skipped it.
|
|
291
|
+
|
|
292
|
+
**Standing orders.** Per-agent `instructions` (set via `add_agent` / `update_agent` or `agent-dispatch update <name> --instructions "..."`) follow the protocol in the same system prompt on **every** dispatch — "read-only SQL only", "never restart a stack unless the task says so", "answer with exact log lines". Unlike `context`, which is per call, and unlike the project's own `CLAUDE.md`, which is written for an interactive session, these are the rules for *being dispatched*.
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
// Response (success, but the agent says it did not finish)
|
|
296
|
+
{
|
|
297
|
+
"agent": "infra",
|
|
298
|
+
"success": true,
|
|
299
|
+
"result": "Restarted horizon. Could not verify the queue drained: the redis container is not reachable from here.",
|
|
300
|
+
"session_id": "sess-abc-123",
|
|
301
|
+
"outcome": "partial",
|
|
302
|
+
"hint": "The agent reports its work is PARTIAL — read the result for what is missing, then continue in the same session via dispatch_session(agent='infra', task='Continue where you left off', session_id='sess-abc-123') or re-dispatch with what it needed."
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
281
306
|
### `dispatch_session`
|
|
282
307
|
|
|
283
308
|
Multi-turn: continue a conversation with an agent. First call starts a session, pass `session_id` back to continue. Never cached.
|
|
@@ -395,6 +420,7 @@ Register a new project directory as an agent. Description is auto-generated from
|
|
|
395
420
|
| `disallowed_tools` | string | no | Comma-separated disallowed tools |
|
|
396
421
|
| `capabilities` | string | no | Comma-separated capability labels (e.g. `"docker_logs,deploy_debug"`) |
|
|
397
422
|
| `risky_capabilities` | string | no | Comma-separated high-risk labels (e.g. `"restart_services"`) |
|
|
423
|
+
| `instructions` | string | no | Standing orders appended to the agent's system prompt on every dispatch (see [the dispatch protocol](#dispatch)) |
|
|
398
424
|
|
|
399
425
|
### `update_agent`
|
|
400
426
|
|
|
@@ -412,6 +438,7 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
|
|
|
412
438
|
| `disallowed_tools` | string | no | Comma-separated. `"none"` to clear |
|
|
413
439
|
| `capabilities` | string | no | Comma-separated. `"none"` to clear |
|
|
414
440
|
| `risky_capabilities` | string | no | Comma-separated. `"none"` to clear |
|
|
441
|
+
| `instructions` | string | no | Standing orders for every dispatch (replaces the text). `"none"` to clear |
|
|
415
442
|
|
|
416
443
|
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`.
|
|
417
444
|
|
|
@@ -515,6 +542,7 @@ Failures are deterministic: check `success`, then branch on `error_type`.
|
|
|
515
542
|
Three soft signals that arrive with `success: true`:
|
|
516
543
|
|
|
517
544
|
- **`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.
|
|
545
|
+
- **`outcome: "partial"` / `"blocked"`** — the agent's own verdict that it did not finish; the result names what is missing and the `hint` carries the `dispatch_session(...)` call to continue in the same session. Not cached, so a re-dispatch after fixing the cause runs fresh.
|
|
518
546
|
- **`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.
|
|
519
547
|
- **`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.
|
|
520
548
|
|
|
@@ -539,6 +567,9 @@ agents:
|
|
|
539
567
|
- deploy_debug
|
|
540
568
|
risky_capabilities: # high-risk labels, surfaced for visibility
|
|
541
569
|
- restart_services
|
|
570
|
+
# instructions: | # standing orders, appended to the system prompt on every dispatch
|
|
571
|
+
# Read-only: never restart or redeploy unless the task says so.
|
|
572
|
+
# Quote exact log lines with timestamps.
|
|
542
573
|
# model: sonnet # optional model override
|
|
543
574
|
# max_budget_usd: 1.0 # cost limit per dispatch
|
|
544
575
|
# permission_mode: bypassPermissions # one of: default | plan | bypassPermissions
|
|
@@ -574,6 +605,9 @@ settings:
|
|
|
574
605
|
# - Edit
|
|
575
606
|
max_dispatch_depth: 3 # recursion protection
|
|
576
607
|
max_concurrency: 5 # max parallel claude -p processes (per dispatch path)
|
|
608
|
+
# dispatch_protocol: true # send every agent the dispatch protocol (non-interactive,
|
|
609
|
+
# # time budget, STATUS line → `outcome`). false = raw claude -p.
|
|
610
|
+
# job_retention_days: 30 # 0 (default) = never prune. See "Job retention" below.
|
|
577
611
|
cache:
|
|
578
612
|
enabled: true
|
|
579
613
|
ttl: 300 # seconds
|
|
@@ -582,6 +616,30 @@ settings:
|
|
|
582
616
|
|
|
583
617
|
Config is reloaded on every tool call — add agents without restarting.
|
|
584
618
|
|
|
619
|
+
### Job retention
|
|
620
|
+
|
|
621
|
+
Every `dispatch_async` **and** every `dispatch(..., return_ref=True)` writes a
|
|
622
|
+
record to `~/.config/agent-dispatch/jobs/`, and nothing deletes it on its own —
|
|
623
|
+
`dispatch_gc` has to be run by hand. The directory therefore grows without
|
|
624
|
+
bound, and `dispatch_jobs` plus the stale-job recovery that runs at every server
|
|
625
|
+
start read and parse *every* file in it.
|
|
626
|
+
|
|
627
|
+
Set `job_retention_days` to prune terminal (done/failed/cancelled) records older
|
|
628
|
+
than N days when a server starts:
|
|
629
|
+
|
|
630
|
+
```yaml
|
|
631
|
+
settings:
|
|
632
|
+
job_retention_days: 30
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
It defaults to `0` — **off** — because those records are your own history of
|
|
636
|
+
past dispatches and deleting them cannot be undone. Pending and running jobs are
|
|
637
|
+
never touched.
|
|
638
|
+
|
|
639
|
+
`agent-dispatch gc --days N` and the `dispatch_gc` tool apply the same rule as a
|
|
640
|
+
one-off. Both *delete* immediately and report the count; neither previews, so
|
|
641
|
+
check what is there first with `agent-dispatch jobs`.
|
|
642
|
+
|
|
585
643
|
### Auto-Description
|
|
586
644
|
|
|
587
645
|
`agent-dispatch add` without `--description` generates one from:
|
|
@@ -621,8 +679,9 @@ agent-dispatch MCP server
|
|
|
621
679
|
▼
|
|
622
680
|
New Claude Code session in ~/projects/infra/
|
|
623
681
|
├─ Inherits: CLAUDE.md, .mcp.json, project tools
|
|
682
|
+
├─ System prompt += dispatch protocol + the agent's standing `instructions`
|
|
624
683
|
├─ Receives structured prompt with goal/caller/context/task
|
|
625
|
-
└─ Returns result →
|
|
684
|
+
└─ Returns result (+ its own STATUS → `outcome`) → cached when complete
|
|
626
685
|
```
|
|
627
686
|
|
|
628
687
|
## Safety
|
|
@@ -634,7 +693,7 @@ agent-dispatch MCP server
|
|
|
634
693
|
- **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.
|
|
635
694
|
- **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`.
|
|
636
695
|
- **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`,
|
|
696
|
+
- **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`, results flagged `budget_exceeded`, and results the agent itself reported as `partial` / `blocked` 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
697
|
- **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.
|
|
639
698
|
|
|
640
699
|
See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
|
|
@@ -645,13 +704,13 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
|
|
|
645
704
|
|---------|-------------|
|
|
646
705
|
| `agent-dispatch init` | Create config + register MCP server with Claude Code |
|
|
647
706
|
| `agent-dispatch add <name> <dir>` | Add an agent (auto-generates description) |
|
|
648
|
-
| `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
|
|
707
|
+
| `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, `--instructions`, etc.) |
|
|
649
708
|
| `agent-dispatch remove <name>` | Remove an agent |
|
|
650
709
|
| `agent-dispatch list` | List agents with health status and permissions |
|
|
651
710
|
| `agent-dispatch group <add\|list\|inspect\|update\|remove>` | Manage [groups](#groups) — cross-project working sets of agents |
|
|
652
711
|
| `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
|
|
653
712
|
| `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
|
|
654
|
-
| `agent-dispatch doctor` | Diagnose installation: Claude CLI, MCP registration, agent health, and group membership |
|
|
713
|
+
| `agent-dispatch doctor` | Diagnose installation: Claude CLI (incl. `--append-system-prompt` support), MCP registration, agent health, and group membership |
|
|
655
714
|
| `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
|
|
656
715
|
| `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
|
|
657
716
|
| `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
|
|
@@ -190,7 +190,8 @@ dispatch(
|
|
|
190
190
|
"session_id": "sess-abc-123",
|
|
191
191
|
"cost_usd": 0.02,
|
|
192
192
|
"duration_ms": 5000,
|
|
193
|
-
"num_turns": 2
|
|
193
|
+
"num_turns": 2,
|
|
194
|
+
"outcome": "done"
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
// Response (failure — error_type helps you handle programmatically)
|
|
@@ -248,6 +249,30 @@ Error: TypeError at scheduler.py:42
|
|
|
248
249
|
Check container logs for recent errors related to the scheduler service
|
|
249
250
|
```
|
|
250
251
|
|
|
252
|
+
**The dispatch protocol and `outcome`.** `claude -p` on its own does not know it is being driven by another agent: given an ambiguous task it will happily end with *"Could you clarify which service you mean?"* — a billed run that answered nothing, and one that a plain cache would then serve again for the whole TTL. So every dispatch also appends a short **dispatch protocol** to the agent's system prompt (`--append-system-prompt`, never mixed into your task text):
|
|
253
|
+
|
|
254
|
+
- it is running non-interactively, dispatched by `caller`, and nobody will answer a question or approve an action — state the assumption and proceed, take the safer reading when two differ;
|
|
255
|
+
- a denied tool or a missing thing is something to report, not something to stop on — finish everything else that is possible;
|
|
256
|
+
- its time budget is about the agent's timeout (and its spend cap, if one is set) — scope the work to fit; a complete partial answer beats an unfinished perfect one;
|
|
257
|
+
- lead with the outcome, then the evidence; list what could not be done and why (this is what makes a `return_ref` summary — the **head** of the text — worth reading);
|
|
258
|
+
- end with one line, `STATUS: done`, `STATUS: partial` or `STATUS: blocked`.
|
|
259
|
+
|
|
260
|
+
That last line is lifted out of `result` into **`outcome`** — the agent's own verdict, deterministic to check: `"done"` means complete; `"partial"` / `"blocked"` mean the agent says the work is unfinished, the result names what is missing, and a `hint` spells out the continuation (usually `dispatch_session(..., session_id=...)`). Read `outcome` before reading the text. It never flips `success`, `partial` / `blocked` results are **not cached**, and a `dispatch_parallel(..., aggregate=...)` labels such members for the aggregator so a blocked report is not synthesized as a finished one. Absent when the agent did not report one — the protocol is off (`settings.dispatch_protocol: false`), `response_format="json"` was requested (the JSON footer governs the reply shape there), or the agent simply skipped it.
|
|
261
|
+
|
|
262
|
+
**Standing orders.** Per-agent `instructions` (set via `add_agent` / `update_agent` or `agent-dispatch update <name> --instructions "..."`) follow the protocol in the same system prompt on **every** dispatch — "read-only SQL only", "never restart a stack unless the task says so", "answer with exact log lines". Unlike `context`, which is per call, and unlike the project's own `CLAUDE.md`, which is written for an interactive session, these are the rules for *being dispatched*.
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
// Response (success, but the agent says it did not finish)
|
|
266
|
+
{
|
|
267
|
+
"agent": "infra",
|
|
268
|
+
"success": true,
|
|
269
|
+
"result": "Restarted horizon. Could not verify the queue drained: the redis container is not reachable from here.",
|
|
270
|
+
"session_id": "sess-abc-123",
|
|
271
|
+
"outcome": "partial",
|
|
272
|
+
"hint": "The agent reports its work is PARTIAL — read the result for what is missing, then continue in the same session via dispatch_session(agent='infra', task='Continue where you left off', session_id='sess-abc-123') or re-dispatch with what it needed."
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
251
276
|
### `dispatch_session`
|
|
252
277
|
|
|
253
278
|
Multi-turn: continue a conversation with an agent. First call starts a session, pass `session_id` back to continue. Never cached.
|
|
@@ -365,6 +390,7 @@ Register a new project directory as an agent. Description is auto-generated from
|
|
|
365
390
|
| `disallowed_tools` | string | no | Comma-separated disallowed tools |
|
|
366
391
|
| `capabilities` | string | no | Comma-separated capability labels (e.g. `"docker_logs,deploy_debug"`) |
|
|
367
392
|
| `risky_capabilities` | string | no | Comma-separated high-risk labels (e.g. `"restart_services"`) |
|
|
393
|
+
| `instructions` | string | no | Standing orders appended to the agent's system prompt on every dispatch (see [the dispatch protocol](#dispatch)) |
|
|
368
394
|
|
|
369
395
|
### `update_agent`
|
|
370
396
|
|
|
@@ -382,6 +408,7 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
|
|
|
382
408
|
| `disallowed_tools` | string | no | Comma-separated. `"none"` to clear |
|
|
383
409
|
| `capabilities` | string | no | Comma-separated. `"none"` to clear |
|
|
384
410
|
| `risky_capabilities` | string | no | Comma-separated. `"none"` to clear |
|
|
411
|
+
| `instructions` | string | no | Standing orders for every dispatch (replaces the text). `"none"` to clear |
|
|
385
412
|
|
|
386
413
|
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`.
|
|
387
414
|
|
|
@@ -485,6 +512,7 @@ Failures are deterministic: check `success`, then branch on `error_type`.
|
|
|
485
512
|
Three soft signals that arrive with `success: true`:
|
|
486
513
|
|
|
487
514
|
- **`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.
|
|
515
|
+
- **`outcome: "partial"` / `"blocked"`** — the agent's own verdict that it did not finish; the result names what is missing and the `hint` carries the `dispatch_session(...)` call to continue in the same session. Not cached, so a re-dispatch after fixing the cause runs fresh.
|
|
488
516
|
- **`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.
|
|
489
517
|
- **`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.
|
|
490
518
|
|
|
@@ -509,6 +537,9 @@ agents:
|
|
|
509
537
|
- deploy_debug
|
|
510
538
|
risky_capabilities: # high-risk labels, surfaced for visibility
|
|
511
539
|
- restart_services
|
|
540
|
+
# instructions: | # standing orders, appended to the system prompt on every dispatch
|
|
541
|
+
# Read-only: never restart or redeploy unless the task says so.
|
|
542
|
+
# Quote exact log lines with timestamps.
|
|
512
543
|
# model: sonnet # optional model override
|
|
513
544
|
# max_budget_usd: 1.0 # cost limit per dispatch
|
|
514
545
|
# permission_mode: bypassPermissions # one of: default | plan | bypassPermissions
|
|
@@ -544,6 +575,9 @@ settings:
|
|
|
544
575
|
# - Edit
|
|
545
576
|
max_dispatch_depth: 3 # recursion protection
|
|
546
577
|
max_concurrency: 5 # max parallel claude -p processes (per dispatch path)
|
|
578
|
+
# dispatch_protocol: true # send every agent the dispatch protocol (non-interactive,
|
|
579
|
+
# # time budget, STATUS line → `outcome`). false = raw claude -p.
|
|
580
|
+
# job_retention_days: 30 # 0 (default) = never prune. See "Job retention" below.
|
|
547
581
|
cache:
|
|
548
582
|
enabled: true
|
|
549
583
|
ttl: 300 # seconds
|
|
@@ -552,6 +586,30 @@ settings:
|
|
|
552
586
|
|
|
553
587
|
Config is reloaded on every tool call — add agents without restarting.
|
|
554
588
|
|
|
589
|
+
### Job retention
|
|
590
|
+
|
|
591
|
+
Every `dispatch_async` **and** every `dispatch(..., return_ref=True)` writes a
|
|
592
|
+
record to `~/.config/agent-dispatch/jobs/`, and nothing deletes it on its own —
|
|
593
|
+
`dispatch_gc` has to be run by hand. The directory therefore grows without
|
|
594
|
+
bound, and `dispatch_jobs` plus the stale-job recovery that runs at every server
|
|
595
|
+
start read and parse *every* file in it.
|
|
596
|
+
|
|
597
|
+
Set `job_retention_days` to prune terminal (done/failed/cancelled) records older
|
|
598
|
+
than N days when a server starts:
|
|
599
|
+
|
|
600
|
+
```yaml
|
|
601
|
+
settings:
|
|
602
|
+
job_retention_days: 30
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
It defaults to `0` — **off** — because those records are your own history of
|
|
606
|
+
past dispatches and deleting them cannot be undone. Pending and running jobs are
|
|
607
|
+
never touched.
|
|
608
|
+
|
|
609
|
+
`agent-dispatch gc --days N` and the `dispatch_gc` tool apply the same rule as a
|
|
610
|
+
one-off. Both *delete* immediately and report the count; neither previews, so
|
|
611
|
+
check what is there first with `agent-dispatch jobs`.
|
|
612
|
+
|
|
555
613
|
### Auto-Description
|
|
556
614
|
|
|
557
615
|
`agent-dispatch add` without `--description` generates one from:
|
|
@@ -591,8 +649,9 @@ agent-dispatch MCP server
|
|
|
591
649
|
▼
|
|
592
650
|
New Claude Code session in ~/projects/infra/
|
|
593
651
|
├─ Inherits: CLAUDE.md, .mcp.json, project tools
|
|
652
|
+
├─ System prompt += dispatch protocol + the agent's standing `instructions`
|
|
594
653
|
├─ Receives structured prompt with goal/caller/context/task
|
|
595
|
-
└─ Returns result →
|
|
654
|
+
└─ Returns result (+ its own STATUS → `outcome`) → cached when complete
|
|
596
655
|
```
|
|
597
656
|
|
|
598
657
|
## Safety
|
|
@@ -604,7 +663,7 @@ agent-dispatch MCP server
|
|
|
604
663
|
- **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.
|
|
605
664
|
- **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`.
|
|
606
665
|
- **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`,
|
|
666
|
+
- **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`, results flagged `budget_exceeded`, and results the agent itself reported as `partial` / `blocked` 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
667
|
- **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.
|
|
609
668
|
|
|
610
669
|
See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
|
|
@@ -615,13 +674,13 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
|
|
|
615
674
|
|---------|-------------|
|
|
616
675
|
| `agent-dispatch init` | Create config + register MCP server with Claude Code |
|
|
617
676
|
| `agent-dispatch add <name> <dir>` | Add an agent (auto-generates description) |
|
|
618
|
-
| `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
|
|
677
|
+
| `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, `--instructions`, etc.) |
|
|
619
678
|
| `agent-dispatch remove <name>` | Remove an agent |
|
|
620
679
|
| `agent-dispatch list` | List agents with health status and permissions |
|
|
621
680
|
| `agent-dispatch group <add\|list\|inspect\|update\|remove>` | Manage [groups](#groups) — cross-project working sets of agents |
|
|
622
681
|
| `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
|
|
623
682
|
| `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
|
|
624
|
-
| `agent-dispatch doctor` | Diagnose installation: Claude CLI, MCP registration, agent health, and group membership |
|
|
683
|
+
| `agent-dispatch doctor` | Diagnose installation: Claude CLI (incl. `--append-system-prompt` support), MCP registration, agent health, and group membership |
|
|
625
684
|
| `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
|
|
626
685
|
| `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
|
|
627
686
|
| `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
|
|
@@ -9,6 +9,13 @@ agents:
|
|
|
9
9
|
risky_capabilities:
|
|
10
10
|
- restart_services
|
|
11
11
|
timeout: 300
|
|
12
|
+
# Standing orders, appended to the agent's system prompt on EVERY dispatch
|
|
13
|
+
# (after the dispatch protocol). Unlike `context`, which is per call, and
|
|
14
|
+
# unlike the project's CLAUDE.md, which is written for an interactive
|
|
15
|
+
# session, these are the rules for being dispatched.
|
|
16
|
+
instructions: |
|
|
17
|
+
Read-only unless the task explicitly asks to restart or redeploy.
|
|
18
|
+
Quote exact log lines with timestamps; never paraphrase an error.
|
|
12
19
|
|
|
13
20
|
# Analytics gateway — a browser + Yandex Metrica agent, no codebase of its own.
|
|
14
21
|
# Its value is its MCP servers + access, not its source. Shared across groups.
|
|
@@ -93,6 +100,12 @@ settings:
|
|
|
93
100
|
default_timeout: 300
|
|
94
101
|
max_dispatch_depth: 3 # recursion protection: A -> B -> A
|
|
95
102
|
max_concurrency: 5 # max parallel claude -p processes
|
|
103
|
+
# dispatch_protocol: true # Append the dispatch protocol to every agent's system
|
|
104
|
+
# prompt: it is non-interactive (never end with a
|
|
105
|
+
# question), its time/spend budget, lead with the
|
|
106
|
+
# outcome, end with `STATUS: done|partial|blocked`
|
|
107
|
+
# (→ DispatchResult.outcome). false = raw claude -p;
|
|
108
|
+
# needed only for CLIs without --append-system-prompt.
|
|
96
109
|
# default_max_budget_usd: 1.0 # spend cap per dispatch, passed to claude as --max-budget-usd
|
|
97
110
|
# (a run stopped at the cap fails with error_type: budget)
|
|
98
111
|
# default_permission_mode: bypassPermissions # inherited by agents without override
|
|
@@ -100,6 +113,12 @@ settings:
|
|
|
100
113
|
# - Bash
|
|
101
114
|
# - Read
|
|
102
115
|
# - Edit
|
|
116
|
+
# job_retention_days: 30 # OFF by default (0). When > 0, terminal job records
|
|
117
|
+
# older than this are deleted at server start. Every
|
|
118
|
+
# async and every return_ref dispatch leaves a job
|
|
119
|
+
# file behind and nothing removes it otherwise, so
|
|
120
|
+
# the directory grows forever — but the records are
|
|
121
|
+
# your dispatch history, so pruning is opt-in.
|
|
103
122
|
cache:
|
|
104
123
|
enabled: true
|
|
105
124
|
ttl: 300 # seconds; identical (agent, task, context) requests are cached
|
|
@@ -102,6 +102,12 @@ class DispatchCache:
|
|
|
102
102
|
# serve the same crippled answer back for the whole TTL and make that
|
|
103
103
|
# recovery a no-op (the permission config is not part of the key).
|
|
104
104
|
return
|
|
105
|
+
if result.outcome in ("partial", "blocked"):
|
|
106
|
+
# The agent itself says the work is unfinished. Whatever it was
|
|
107
|
+
# missing (a service that was down, a file that did not exist yet)
|
|
108
|
+
# is not in the key either, so a retry after fixing it would be
|
|
109
|
+
# served the same unfinished answer for the whole TTL.
|
|
110
|
+
return
|
|
105
111
|
key = self._make_key(agent, task, context, caller, goal, response_format)
|
|
106
112
|
with self._lock:
|
|
107
113
|
# Bound memory: when at capacity and inserting a new key, evict the
|