agent-dispatch 0.12.0__tar.gz → 0.12.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/AGENTS.md +3 -3
  2. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/CHANGELOG.md +47 -1
  3. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/PKG-INFO +6 -6
  4. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/README.md +5 -5
  5. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/pyproject.toml +1 -1
  6. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/__init__.py +1 -1
  7. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/cli.py +45 -6
  8. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/config.py +9 -0
  9. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/runner.py +28 -0
  10. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/server.py +53 -8
  11. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_cli.py +58 -0
  12. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_runner.py +70 -0
  13. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_server.py +98 -0
  14. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/.github/dependabot.yml +0 -0
  15. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/.github/workflows/ci.yml +0 -0
  16. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/.github/workflows/publish.yml +0 -0
  17. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/.gitignore +0 -0
  18. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/LICENSE +0 -0
  19. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/SECURITY.md +0 -0
  20. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/agents.example.yaml +0 -0
  21. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/assets/mascot.png +0 -0
  22. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/cache.py +0 -0
  23. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/jobs.py +0 -0
  24. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/models.py +0 -0
  25. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/__init__.py +0 -0
  26. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/conftest.py +0 -0
  27. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_cache.py +0 -0
  28. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_config.py +0 -0
  29. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_jobs.py +0 -0
  30. {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_models.py +0 -0
@@ -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 # 561 tests, ~4s
31
+ python3 -m pytest tests/ -v # 578 tests, ~5s
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.
@@ -58,7 +58,7 @@ Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.whi
58
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
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
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.
61
+ - MCP tools that load config carry `@_config_guard` under `@mcp.tool()` so a broken `agents.yaml` — or a failed *write* — returns the `{"error": ...}` envelope instead of a raw traceback. The set of load errors lives in one place (`config.CONFIG_LOAD_ERRORS`) because three surfaces handle it: **`UnicodeDecodeError` is a `ValueError`, not an `OSError`**, and listing types per-site is exactly how a cp1251 config slipped past all three.
62
62
 
63
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
64
 
@@ -76,4 +76,4 @@ Python ≥ 3.10 · `from __future__ import annotations` everywhere · Pydantic v
76
76
 
77
77
  ## More detail
78
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`, ...).
79
+ [README.md](README.md) documents every MCP tool with parameter tables, response shapes, and the error-recovery map — it doubles as the behavioral spec. The test suite (`tests/`, 578 tests) encodes the exact expected behavior of every layer: when in doubt, read the tests for the module you're touching (`test_runner.py`, `test_server.py`, `test_cli.py`, ...).
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.1] - 2026-07-30
11
+
12
+ Two holes in 0.12.0's own "tools always return a clean error" fix.
13
+
14
+ ### Fixed
15
+ - **A non-UTF-8 `agents.yaml` no longer crashes every surface.** `UnicodeDecodeError`
16
+ is a `ValueError`, not an `OSError`, so a config saved as cp1251/UTF-16 slipped
17
+ past all three handlers added in 0.12.0 and raised a bare traceback out of every
18
+ MCP tool, `agent-dispatch list` — and `doctor`, the command you run *because*
19
+ the config is broken. The set of load errors is now declared once
20
+ (`config.CONFIG_LOAD_ERRORS`) and shared by all three, and `doctor` reports the
21
+ encoding (and an unreadable file) as a normal FAIL with a fix. The shared set
22
+ lives in `config.CONFIG_LOAD_ERRORS`; the CLI still branches per type because
23
+ each one deserves a different remediation line.
24
+ - **An undecodable byte from the `claude` CLI no longer kills a paid-for
25
+ dispatch.** `text=True` decodes strictly, so one invalid byte on stdout raised
26
+ `UnicodeDecodeError` — a `ValueError`, caught by nothing in the runner — out of
27
+ `dispatch`, `dispatch_stream`, the MCP tools and `agent-dispatch test`. Both
28
+ spawn sites now decode with `errors="replace"`, so a mangled byte becomes
29
+ U+FFFD instead of discarding the run. (Present since the initial commit.)
30
+ - **`dispatch` classifies spawn failures like `dispatch_stream` already did.**
31
+ A directory that passes `is_dir()` but cannot be entered — or that vanishes
32
+ between the check and the spawn — made `subprocess.run` raise straight through
33
+ the `except subprocess.TimeoutExpired`. It now returns `not_found` /
34
+ `permission` / `cli_error` like the streaming path.
35
+ - **The six job tools got the I/O envelope too.** `dispatch_status`, `_wait`,
36
+ `_cancel`, `_jobs`, `fetch_result` and `dispatch_gc` never load config, so they
37
+ carried no guard — an unwritable or misconfigured jobs directory raised out of
38
+ them. The guard is now split (`_io_guard` / `_config_guard`) and both halves
39
+ are applied where each is needed.
40
+ - **`add_agent` reports an unresolvable path** (`~unknown-user`) as an error
41
+ envelope instead of raising `RuntimeError`.
42
+ - **A failed config *write* is reported, not raised.** The same contract had the
43
+ other half missing: a full disk or a read-only volume made `save_config`
44
+ raise OSError straight out of `add_agent`/`update_agent`/`remove_agent` and out
45
+ of the matching CLI commands. Both surfaces now report it — the CLI adds that
46
+ the previous config is intact, which the atomic write guarantees.
47
+
48
+ ### Changed
49
+ - Docs corrected against the code: stale-job recovery now documents the
50
+ `pending` sweep and its 24h threshold; the config-lock guarantee is stated as
51
+ best-effort (it proceeds unlocked after 10s rather than freezing the server);
52
+ `add_agent(timeout=0)` documents that it stores the literal 300, not
53
+ `settings.default_timeout`.
54
+
10
55
  ## [0.12.0] - 2026-07-29
11
56
 
12
57
  Reliability pass over the streaming path, config durability, and the tool
@@ -546,7 +591,8 @@ cache bounding, and stale-job recovery.
546
591
  - Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
547
592
  commit SHAs for supply-chain integrity.
548
593
 
549
- [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.12.0...HEAD
594
+ [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.12.1...HEAD
595
+ [0.12.1]: https://github.com/ginkida/agent-dispatch/compare/v0.12.0...v0.12.1
550
596
  [0.12.0]: https://github.com/ginkida/agent-dispatch/compare/v0.11.0...v0.12.0
551
597
  [0.11.0]: https://github.com/ginkida/agent-dispatch/compare/v0.10.0...v0.11.0
552
598
  [0.10.0]: https://github.com/ginkida/agent-dispatch/compare/v0.9.0...v0.10.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.12.0
3
+ Version: 0.12.1
4
4
  Summary: MCP server that lets Claude Code agents delegate tasks to agents in other project directories
5
5
  Project-URL: Homepage, https://github.com/ginkida/agent-dispatch
6
6
  Project-URL: Repository, https://github.com/ginkida/agent-dispatch
@@ -388,8 +388,8 @@ Register a new project directory as an agent. Description is auto-generated from
388
388
  | `name` | string | yes | Agent name (letters, digits, hyphens, underscores) |
389
389
  | `directory` | string | yes | Path to an existing project directory (`~` is expanded, relative paths resolved) |
390
390
  | `description` | string | no | What this agent can do — auto-generated if empty |
391
- | `timeout` | int | no | Timeout in seconds (0 = use global default) |
392
- | `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = no limit) |
391
+ | `timeout` | int | no | Timeout in seconds (0 = 300; this is a literal default, not `settings.default_timeout`) |
392
+ | `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = inherit `settings.default_max_budget_usd`; no cap only when that is unset too) |
393
393
  | `permission_mode` | string | no | Permission mode (e.g. `default`, `plan`, `bypassPermissions`) |
394
394
  | `allowed_tools` | string | no | Comma-separated allowed tools (e.g. `"Bash,Read,Edit"`) |
395
395
  | `disallowed_tools` | string | no | Comma-separated disallowed tools |
@@ -405,7 +405,7 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
405
405
  | `name` | string | yes | Agent name to update |
406
406
  | `description` | string | no | New description |
407
407
  | `timeout` | int | no | New timeout (0 = don't change) |
408
- | `max_budget_usd` | float | no | New budget limit (0 = don't change, negative = clear the limit) |
408
+ | `max_budget_usd` | float | no | New budget limit (0 = don't change; negative clears the *per-agent* cap, after which `settings.default_max_budget_usd` applies) |
409
409
  | `model` | string | no | Model override. `"none"` to clear |
410
410
  | `permission_mode` | string | no | Permission mode. `"none"` to clear |
411
411
  | `allowed_tools` | string | no | Comma-separated. `"none"` to clear |
@@ -474,7 +474,7 @@ Async workers run with streaming under the hood: the job file keeps a rolling ta
474
474
 
475
475
  `dispatch_jobs(status?)` lists recent jobs as summaries (filter by `pending` / `running` / `done` / `failed` / `cancelled`). `dispatch_gc(max_age_days=7)` purges terminal jobs older than the threshold — pending and running jobs are never deleted.
476
476
 
477
- Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server marks jobs left in `running` by a crashed instance as `failed` once they are stale (stuck for over an hour).
477
+ Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server recovers jobs a crashed instance abandoned: `running` ones stuck over an hour, and `pending` ones over 24 hours, are marked `failed` so they stop being polled forever and become collectable by `dispatch_gc`. (The `pending` threshold is deliberately long — the jobs directory is shared by every running server, so a job queued behind another server's concurrency limit must not be swept.)
478
478
 
479
479
  | When to use async | When to use `dispatch` |
480
480
  |-------------------|------------------------|
@@ -635,7 +635,7 @@ agent-dispatch MCP server
635
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`.
636
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
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.
638
+ - **Durable config** — `agents.yaml` is written atomically (temp file + rename), so an interrupted write can never truncate it. Every mutation path (CLI and MCP server alike) also takes a cross-process advisory lock, so concurrent edits don't drop one another's agents. The lock is best-effort by design: after waiting 10 seconds it logs a warning and proceeds anyway, because a wedged lock holder must not freeze the MCP server — so on a heavily contended config a lost update is possible, while a truncated one is not.
639
639
 
640
640
  See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
641
641
 
@@ -358,8 +358,8 @@ Register a new project directory as an agent. Description is auto-generated from
358
358
  | `name` | string | yes | Agent name (letters, digits, hyphens, underscores) |
359
359
  | `directory` | string | yes | Path to an existing project directory (`~` is expanded, relative paths resolved) |
360
360
  | `description` | string | no | What this agent can do — auto-generated if empty |
361
- | `timeout` | int | no | Timeout in seconds (0 = use global default) |
362
- | `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = no limit) |
361
+ | `timeout` | int | no | Timeout in seconds (0 = 300; this is a literal default, not `settings.default_timeout`) |
362
+ | `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = inherit `settings.default_max_budget_usd`; no cap only when that is unset too) |
363
363
  | `permission_mode` | string | no | Permission mode (e.g. `default`, `plan`, `bypassPermissions`) |
364
364
  | `allowed_tools` | string | no | Comma-separated allowed tools (e.g. `"Bash,Read,Edit"`) |
365
365
  | `disallowed_tools` | string | no | Comma-separated disallowed tools |
@@ -375,7 +375,7 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
375
375
  | `name` | string | yes | Agent name to update |
376
376
  | `description` | string | no | New description |
377
377
  | `timeout` | int | no | New timeout (0 = don't change) |
378
- | `max_budget_usd` | float | no | New budget limit (0 = don't change, negative = clear the limit) |
378
+ | `max_budget_usd` | float | no | New budget limit (0 = don't change; negative clears the *per-agent* cap, after which `settings.default_max_budget_usd` applies) |
379
379
  | `model` | string | no | Model override. `"none"` to clear |
380
380
  | `permission_mode` | string | no | Permission mode. `"none"` to clear |
381
381
  | `allowed_tools` | string | no | Comma-separated. `"none"` to clear |
@@ -444,7 +444,7 @@ Async workers run with streaming under the hood: the job file keeps a rolling ta
444
444
 
445
445
  `dispatch_jobs(status?)` lists recent jobs as summaries (filter by `pending` / `running` / `done` / `failed` / `cancelled`). `dispatch_gc(max_age_days=7)` purges terminal jobs older than the threshold — pending and running jobs are never deleted.
446
446
 
447
- Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server marks jobs left in `running` by a crashed instance as `failed` once they are stale (stuck for over an hour).
447
+ Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server recovers jobs a crashed instance abandoned: `running` ones stuck over an hour, and `pending` ones over 24 hours, are marked `failed` so they stop being polled forever and become collectable by `dispatch_gc`. (The `pending` threshold is deliberately long — the jobs directory is shared by every running server, so a job queued behind another server's concurrency limit must not be swept.)
448
448
 
449
449
  | When to use async | When to use `dispatch` |
450
450
  |-------------------|------------------------|
@@ -605,7 +605,7 @@ agent-dispatch MCP server
605
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`.
606
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
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.
608
+ - **Durable config** — `agents.yaml` is written atomically (temp file + rename), so an interrupted write can never truncate it. Every mutation path (CLI and MCP server alike) also takes a cross-process advisory lock, so concurrent edits don't drop one another's agents. The lock is best-effort by design: after waiting 10 seconds it logs a warning and proceeds anyway, because a wedged lock holder must not freeze the MCP server — so on a heavily contended config a lost update is possible, while a truncated one is not.
609
609
 
610
610
  See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
611
611
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.12.0"
3
+ version = "0.12.1"
4
4
  description = "MCP server that lets Claude Code agents delegate tasks to agents in other project directories"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """agent-dispatch: Delegate tasks between Claude Code agents across projects."""
2
2
 
3
- __version__ = "0.12.0"
3
+ __version__ = "0.12.1"
@@ -29,6 +29,27 @@ def _parse_csv(value: str | None) -> list[str] | None:
29
29
  return [t.strip() for t in value.split(",") if t.strip()] if value else None
30
30
 
31
31
 
32
+ def _save_or_exit(config: DispatchConfig) -> None:
33
+ """Persist the config, turning an I/O failure into a message, not a traceback.
34
+
35
+ A full disk or a read-only volume makes `save_config` raise OSError. The
36
+ write is atomic, so the previous config survives — say so, since that is the
37
+ one thing the user needs to know.
38
+ """
39
+ try:
40
+ save_config(config)
41
+ except OSError as e:
42
+ click.echo(
43
+ click.style(
44
+ f"Error: could not write {config_path()}: {e}\n"
45
+ "Nothing was changed — the previous config is intact "
46
+ "(the write is atomic). Check free space and permissions.",
47
+ fg="red",
48
+ )
49
+ )
50
+ raise SystemExit(1) from None
51
+
52
+
32
53
  def _check_budget_or_exit(max_budget: float | None) -> None:
33
54
  """Reject a negative spend cap before it reaches `AgentConfig` (ge=0).
34
55
 
@@ -75,6 +96,15 @@ def _load_or_exit() -> DispatchConfig:
75
96
  click.echo(click.style(f"Error: config at {config_path()} is not valid YAML:", fg="red"))
76
97
  click.echo(str(e))
77
98
  raise SystemExit(1) from None
99
+ except UnicodeDecodeError as e:
100
+ click.echo(
101
+ click.style(
102
+ f"Error: config at {config_path()} is not valid UTF-8 (re-save it as UTF-8):",
103
+ fg="red",
104
+ )
105
+ )
106
+ click.echo(str(e))
107
+ raise SystemExit(1) from None
78
108
  except OSError as e:
79
109
  click.echo(click.style(f"Error: config at {config_path()} could not be read:", fg="red"))
80
110
  click.echo(str(e))
@@ -236,7 +266,7 @@ def add(
236
266
  if warning := check_permission_mode(permission_mode):
237
267
  click.echo(click.style(f"Warning: {warning}", fg="yellow"))
238
268
 
239
- save_config(config)
269
+ _save_or_exit(config)
240
270
  click.echo(f"Added agent '{name}' -> {dir_path}")
241
271
 
242
272
 
@@ -251,7 +281,7 @@ def remove(name: str) -> None:
251
281
  raise SystemExit(1)
252
282
 
253
283
  del config.agents[name]
254
- save_config(config)
284
+ _save_or_exit(config)
255
285
  click.echo(f"Removed agent '{name}'.")
256
286
 
257
287
 
@@ -381,7 +411,7 @@ def update(
381
411
  click.echo("Nothing to update. Pass at least one option (see --help).")
382
412
  raise SystemExit(1)
383
413
 
384
- save_config(config)
414
+ _save_or_exit(config)
385
415
  click.echo(f"Updated agent '{name}': {', '.join(updated)}")
386
416
 
387
417
 
@@ -639,7 +669,7 @@ def group_add(name: str, description: str, shared_context: str, members: tuple[s
639
669
  shared_context=shared_context,
640
670
  members=member_objs,
641
671
  )
642
- save_config(config)
672
+ _save_or_exit(config)
643
673
  click.echo(f"Added group '{name}' ({len(member_objs)} member(s)).")
644
674
  if not member_objs:
645
675
  click.echo(
@@ -737,7 +767,7 @@ def group_update(name: str, description: str | None, shared_context: str | None)
737
767
  click.echo("Nothing to update. Pass --description and/or --shared-context.")
738
768
  raise SystemExit(1)
739
769
 
740
- save_config(config)
770
+ _save_or_exit(config)
741
771
  click.echo(f"Updated group '{name}': {', '.join(updated)}")
742
772
 
743
773
 
@@ -752,7 +782,7 @@ def group_remove(name: str) -> None:
752
782
  raise SystemExit(1)
753
783
 
754
784
  del config.groups[name]
755
- save_config(config)
785
+ _save_or_exit(config)
756
786
  click.echo(f"Removed group '{name}'.")
757
787
 
758
788
 
@@ -807,6 +837,15 @@ def doctor() -> None:
807
837
  except yaml.YAMLError as e:
808
838
  fail(f"Config not valid YAML: {cp}")
809
839
  click.echo(f" {e}")
840
+ except UnicodeDecodeError as e:
841
+ # A ValueError, not an OSError — it slipped past both handlers above
842
+ # and crashed the one command meant to diagnose a broken config.
843
+ fail(f"Config is not valid UTF-8: {cp}")
844
+ click.echo(f" {e}")
845
+ click.echo(" Re-save the file as UTF-8.")
846
+ except OSError as e:
847
+ fail(f"Config could not be read: {cp}")
848
+ click.echo(f" {e}")
810
849
 
811
850
  section("MCP registration")
812
851
  if claude_path is None:
@@ -13,6 +13,7 @@ from collections.abc import Iterator
13
13
  from pathlib import Path
14
14
 
15
15
  import yaml
16
+ from pydantic import ValidationError
16
17
 
17
18
  from .models import DispatchConfig
18
19
 
@@ -150,6 +151,14 @@ def file_lock(path: Path) -> Iterator[None]:
150
151
  _release_lock(fd)
151
152
 
152
153
 
154
+ # Everything load_config() can raise for a config a human can plausibly produce.
155
+ # Listed once because three surfaces handle it independently (server._get_config,
156
+ # cli._load_or_exit, cli.doctor) and they must not drift: UnicodeDecodeError, in
157
+ # particular, is a ValueError — NOT an OSError — so a file saved in cp1251 or
158
+ # UTF-16 used to escape every one of them as a raw traceback.
159
+ CONFIG_LOAD_ERRORS = (ValidationError, yaml.YAMLError, UnicodeDecodeError, OSError)
160
+
161
+
153
162
  def load_config(path: Path | None = None) -> DispatchConfig:
154
163
  """Load config from YAML file. Returns empty config if file missing."""
155
164
  p = path or config_path()
@@ -638,6 +638,13 @@ def dispatch(
638
638
  cwd=str(agent.directory),
639
639
  capture_output=True,
640
640
  text=True,
641
+ # Decoding must be total. `text=True` alone decodes strictly, so
642
+ # a single undecodable byte on stdout/stderr raises
643
+ # UnicodeDecodeError — a ValueError, caught by nothing here — and
644
+ # an already-billed dispatch escapes as a raw exception instead
645
+ # of a DispatchResult. Same rule as _read_preview.
646
+ encoding="utf-8",
647
+ errors="replace",
641
648
  timeout=timeout,
642
649
  env=env,
643
650
  )
@@ -650,6 +657,25 @@ def dispatch(
650
657
  error=_timeout_error(agent_name, timeout, session_uuid),
651
658
  error_type="timeout",
652
659
  )
660
+ # Spawn failures, classified exactly as dispatch_stream does — the
661
+ # is_dir() check above does not prove the child can chdir into it, and
662
+ # the directory can vanish between the check and the spawn.
663
+ except FileNotFoundError as e:
664
+ return DispatchResult(
665
+ agent=agent_name, success=False, result="", error=str(e), error_type="not_found"
666
+ )
667
+ except PermissionError as e:
668
+ return DispatchResult(
669
+ agent=agent_name,
670
+ success=False,
671
+ result="",
672
+ error=f"{e}{_permission_hint(agent_name)}",
673
+ error_type="permission",
674
+ )
675
+ except OSError as e:
676
+ return DispatchResult(
677
+ agent=agent_name, success=False, result="", error=str(e), error_type="cli_error"
678
+ )
653
679
  # Self-heal on old claude CLIs that don't know --session-id: strip the
654
680
  # flag and retry once. Timed-out dispatches lose resumability, but
655
681
  # every dispatch working beats 100% failing with "unknown option".
@@ -869,6 +895,8 @@ def dispatch_stream(
869
895
  stdout=subprocess.PIPE,
870
896
  stderr=subprocess.PIPE,
871
897
  text=True,
898
+ encoding="utf-8",
899
+ errors="replace", # see dispatch(): strict decoding raises mid-stream
872
900
  env=env,
873
901
  # Own process group, so the timeout can take the whole tree down
874
902
  # (see _kill_process_tree). Without it a backgrounded grandchild
@@ -14,13 +14,12 @@ import time
14
14
  from collections.abc import Awaitable, Callable
15
15
  from typing import Any
16
16
 
17
- import yaml
18
17
  from mcp.server.fastmcp import Context, FastMCP
19
- from pydantic import ValidationError
20
18
 
21
19
  from . import runner
22
20
  from .cache import DispatchCache
23
21
  from .config import (
22
+ CONFIG_LOAD_ERRORS,
24
23
  auto_describe,
25
24
  collect_mcp_servers,
26
25
  config_lock,
@@ -123,10 +122,40 @@ def _get_config() -> DispatchConfig:
123
122
  """Load config fresh each call so new agents are picked up immediately."""
124
123
  try:
125
124
  return load_config()
126
- except (ValidationError, yaml.YAMLError, OSError) as e:
125
+ except CONFIG_LOAD_ERRORS as e:
127
126
  raise ConfigLoadError(f"Config at {config_path()} could not be loaded: {e}") from e
128
127
 
129
128
 
129
+ def _io_guard(
130
+ fn: Callable[..., Awaitable[str]],
131
+ ) -> Callable[..., Awaitable[str]]:
132
+ """Return the documented ``{"error": ...}`` envelope for a filesystem failure.
133
+
134
+ Reading a broken config is not the only way a tool fails on I/O: a full disk,
135
+ a read-only volume or an uncreatable jobs directory makes ``save_config`` and
136
+ ``JobStore`` raise, and that used to escape as a raw exception. Runner-level
137
+ I/O never reaches here — it is already turned into a ``DispatchResult``.
138
+ """
139
+
140
+ @functools.wraps(fn)
141
+ async def wrapper(*args: Any, **kwargs: Any) -> str:
142
+ try:
143
+ return await fn(*args, **kwargs)
144
+ except OSError as e:
145
+ logger.warning("%s: I/O error: %s", fn.__name__, e)
146
+ return json.dumps(
147
+ {
148
+ "error": f"{fn.__name__} failed on I/O: {e}",
149
+ "hint": "Check free space and permissions on the config and jobs "
150
+ "directories (~/.config/agent-dispatch, or AGENT_DISPATCH_CONFIG / "
151
+ "AGENT_DISPATCH_JOBS_DIR).",
152
+ },
153
+ indent=2,
154
+ )
155
+
156
+ return wrapper
157
+
158
+
130
159
  def _config_guard(
131
160
  fn: Callable[..., Awaitable[str]],
132
161
  ) -> Callable[..., Awaitable[str]]:
@@ -153,7 +182,8 @@ def _config_guard(
153
182
  indent=2,
154
183
  )
155
184
 
156
- return wrapper
185
+ # The I/O arm is identical for every tool — reuse it instead of duplicating.
186
+ return _io_guard(wrapper)
157
187
 
158
188
 
159
189
  def _get_cache(config: DispatchConfig) -> DispatchCache | None:
@@ -1399,7 +1429,9 @@ async def add_agent(
1399
1429
  directory: Absolute path to the project directory.
1400
1430
  description: What this agent can do. Leave empty for auto-generation.
1401
1431
  timeout: Timeout in seconds (0 uses global default of 300).
1402
- max_budget_usd: Max cost in USD per dispatch (0 or omitted = no limit).
1432
+ max_budget_usd: Max cost in USD per dispatch. 0 or omitted inherits
1433
+ settings.default_max_budget_usd — that is "no limit" only when the
1434
+ setting is unset too.
1403
1435
  permission_mode: Permission mode for the claude CLI
1404
1436
  (e.g. default, plan, bypassPermissions). Leave empty for default.
1405
1437
  allowed_tools: Comma-separated list of allowed tools
@@ -1419,7 +1451,13 @@ async def add_agent(
1419
1451
 
1420
1452
  from pathlib import Path
1421
1453
 
1422
- dir_path = Path(directory).expanduser().resolve()
1454
+ try:
1455
+ # expanduser raises RuntimeError for an unresolvable "~unknown-user",
1456
+ # and resolve() can raise OSError — neither is a directory-missing error,
1457
+ # and both used to escape the tool as a raw exception.
1458
+ dir_path = Path(directory).expanduser().resolve()
1459
+ except (OSError, RuntimeError, ValueError) as e:
1460
+ return json.dumps({"error": f"Invalid directory {directory!r}: {e}"})
1423
1461
  if not dir_path.is_dir():
1424
1462
  return json.dumps({"error": f"Directory does not exist: {dir_path}"})
1425
1463
 
@@ -1533,8 +1571,9 @@ async def update_agent(
1533
1571
  name: Agent name to update.
1534
1572
  description: New description.
1535
1573
  timeout: New timeout in seconds (0 = don't change).
1536
- max_budget_usd: New max cost in USD per dispatch (0 = don't change;
1537
- pass a negative number to clear the limit).
1574
+ max_budget_usd: New max cost in USD per dispatch (0 = don't change).
1575
+ A negative number clears the *per-agent* cap, after which
1576
+ settings.default_max_budget_usd applies (if set).
1538
1577
  model: Model override. Pass "none" to clear.
1539
1578
  permission_mode: Permission mode. Pass "none" to clear.
1540
1579
  allowed_tools: Comma-separated allowed tools. Pass "none" to clear.
@@ -1813,6 +1852,7 @@ async def dispatch_async(
1813
1852
 
1814
1853
 
1815
1854
  @mcp.tool()
1855
+ @_io_guard
1816
1856
  async def dispatch_status(
1817
1857
  job_id: str,
1818
1858
  ctx: Context | None = None,
@@ -1836,6 +1876,7 @@ async def dispatch_status(
1836
1876
 
1837
1877
 
1838
1878
  @mcp.tool()
1879
+ @_io_guard
1839
1880
  async def dispatch_wait(
1840
1881
  job_id: str,
1841
1882
  timeout_seconds: int = 60,
@@ -1876,6 +1917,7 @@ async def dispatch_wait(
1876
1917
 
1877
1918
 
1878
1919
  @mcp.tool()
1920
+ @_io_guard
1879
1921
  async def dispatch_cancel(
1880
1922
  job_id: str,
1881
1923
  ctx: Context | None = None,
@@ -1935,6 +1977,7 @@ async def dispatch_cancel(
1935
1977
 
1936
1978
 
1937
1979
  @mcp.tool()
1980
+ @_io_guard
1938
1981
  async def dispatch_jobs(
1939
1982
  status: str = "",
1940
1983
  limit: int = 50,
@@ -1991,6 +2034,7 @@ async def dispatch_jobs(
1991
2034
 
1992
2035
 
1993
2036
  @mcp.tool()
2037
+ @_io_guard
1994
2038
  async def fetch_result(
1995
2039
  ref: str,
1996
2040
  max_chars: int = 0,
@@ -2030,6 +2074,7 @@ async def fetch_result(
2030
2074
 
2031
2075
 
2032
2076
  @mcp.tool()
2077
+ @_io_guard
2033
2078
  async def dispatch_gc(
2034
2079
  max_age_days: float = 7,
2035
2080
  ctx: Context | None = None,
@@ -1434,3 +1434,61 @@ class TestAddBudgetGuard:
1434
1434
  assert "--max-budget" in result.output
1435
1435
  assert not isinstance(result.exception, ValidationError)
1436
1436
  assert not _isolated_config.exists()
1437
+
1438
+
1439
+ class TestNonUtf8Config:
1440
+ def _write_cp1251(self, path: Path) -> None:
1441
+ path.write_bytes(
1442
+ 'agents:\n infra:\n directory: /tmp\n description: "Инфраструктура"\n'.encode(
1443
+ "cp1251"
1444
+ )
1445
+ )
1446
+
1447
+ def test_list_reports_encoding_not_traceback(self, _isolated_config):
1448
+ self._write_cp1251(_isolated_config)
1449
+ result = runner.invoke(cli, ["list"])
1450
+ assert result.exit_code == 1
1451
+ assert "UTF-8" in result.output
1452
+ assert not isinstance(result.exception, UnicodeDecodeError)
1453
+
1454
+ def test_doctor_diagnoses_instead_of_crashing(self, _isolated_config):
1455
+ # doctor is the command you reach for when the config is broken — it
1456
+ # must never be the one that crashes on it.
1457
+ self._write_cp1251(_isolated_config)
1458
+ result = runner.invoke(cli, ["doctor"])
1459
+ assert not isinstance(result.exception, UnicodeDecodeError)
1460
+ assert "not valid UTF-8" in result.output
1461
+ assert "Re-save the file as UTF-8" in result.output
1462
+
1463
+ def test_unreadable_config_is_reported_by_doctor(self, _isolated_config, monkeypatch):
1464
+ _isolated_config.write_text("agents: {}\n")
1465
+ monkeypatch.setattr(
1466
+ "agent_dispatch.cli.load_config",
1467
+ lambda *a, **kw: (_ for _ in ()).throw(OSError("permission denied")),
1468
+ )
1469
+ result = runner.invoke(cli, ["doctor"])
1470
+ assert not isinstance(result.exception, OSError)
1471
+ assert "could not be read" in result.output
1472
+
1473
+
1474
+ class TestWriteFailureMessages:
1475
+ def test_add_reports_write_failure_without_traceback(self, tmp_path: Path, _isolated_config):
1476
+ project = tmp_path / "proj"
1477
+ project.mkdir()
1478
+ with patch(
1479
+ "agent_dispatch.cli.save_config", side_effect=OSError(28, "No space left on device")
1480
+ ):
1481
+ result = runner.invoke(cli, ["add", "proj", str(project), "-d", "t"])
1482
+ assert result.exit_code == 1
1483
+ assert "could not write" in result.output
1484
+ assert "previous config is intact" in result.output
1485
+ assert not isinstance(result.exception, OSError)
1486
+
1487
+ def test_previous_config_survives_a_failed_write(self, tmp_path: Path, _isolated_config):
1488
+ project = tmp_path / "proj"
1489
+ project.mkdir()
1490
+ runner.invoke(cli, ["add", "keep", str(project), "-d", "t"])
1491
+ with patch("agent_dispatch.cli.save_config", side_effect=OSError("disk full")):
1492
+ runner.invoke(cli, ["remove", "keep"])
1493
+ # The write is atomic, so the agent must still be there.
1494
+ assert "keep" in load_config(_isolated_config).agents
@@ -1866,3 +1866,73 @@ class TestDispatchPayloadRobustness:
1866
1866
  assert cmd[2] == "--output-format" # prompt untouched
1867
1867
  assert cmd[cmd.index("--output-format", 3) + 1] == "stream-json"
1868
1868
  assert result.success
1869
+
1870
+
1871
+ class TestUndecodableOutput:
1872
+ """`text=True` decodes strictly: one bad byte used to raise UnicodeDecodeError
1873
+ (a ValueError — caught by nothing) out of an already-billed dispatch."""
1874
+
1875
+ @staticmethod
1876
+ def _cli(tmp_path: Path, body: str) -> Path:
1877
+ script = tmp_path / "bad_cli.py"
1878
+ script.write_text(body)
1879
+ launcher = tmp_path / "bad_cli"
1880
+ launcher.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{script}" "$@"\n')
1881
+ launcher.chmod(0o755)
1882
+ return launcher
1883
+
1884
+ def test_dispatch_survives_an_undecodable_byte(self, tmp_path: Path):
1885
+ cli = self._cli(
1886
+ tmp_path,
1887
+ "import sys\n"
1888
+ "sys.stdout.buffer.write(b'{\"result\":\"caf\\xe9 ok\",\"is_error\":false}')\n",
1889
+ )
1890
+ agent = AgentConfig(directory=tmp_path, description="t", timeout=10)
1891
+ with patch("agent_dispatch.runner.shutil.which", return_value=str(cli)):
1892
+ result = dispatch("test", "hello", agent, Settings())
1893
+ assert result.success
1894
+ assert "ok" in result.result # the bad byte became U+FFFD, nothing raised
1895
+
1896
+ def test_stream_survives_an_undecodable_byte(self, tmp_path: Path):
1897
+ cli = self._cli(
1898
+ tmp_path,
1899
+ "import sys\n"
1900
+ "sys.stdout.buffer.write(b'{\"type\":\"result\",\"is_error\":false,"
1901
+ "\"result\":\"caf\\xe9 ok\"}\\n')\n",
1902
+ )
1903
+ agent = AgentConfig(directory=tmp_path, description="t", timeout=10)
1904
+ with patch("agent_dispatch.runner.shutil.which", return_value=str(cli)):
1905
+ result = dispatch_stream("test", "hello", agent, Settings())
1906
+ assert result.success
1907
+ assert "ok" in result.result
1908
+
1909
+
1910
+ class TestSpawnFailureClassification:
1911
+ """dispatch_stream classified spawn errors; dispatch let them escape raw."""
1912
+
1913
+ def setup_method(self):
1914
+ self.settings = Settings()
1915
+
1916
+ @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
1917
+ @patch("agent_dispatch.runner.subprocess.run", side_effect=FileNotFoundError("gone"))
1918
+ def test_missing_cwd_at_spawn_is_not_found(self, _run, _which):
1919
+ # is_dir() passing does not prove the child can chdir there, and the
1920
+ # directory can vanish between the check and the spawn.
1921
+ result = dispatch("test", "hi", AgentConfig(directory="/tmp", timeout=10), self.settings)
1922
+ assert not result.success
1923
+ assert result.error_type == "not_found"
1924
+
1925
+ @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
1926
+ @patch("agent_dispatch.runner.subprocess.run", side_effect=PermissionError("denied"))
1927
+ def test_unexecutable_cwd_is_permission(self, _run, _which):
1928
+ result = dispatch("test", "hi", AgentConfig(directory="/tmp", timeout=10), self.settings)
1929
+ assert not result.success
1930
+ assert result.error_type == "permission"
1931
+ assert "Hint" in result.error
1932
+
1933
+ @patch("agent_dispatch.runner.shutil.which", return_value="/usr/bin/claude")
1934
+ @patch("agent_dispatch.runner.subprocess.run", side_effect=OSError("too many open files"))
1935
+ def test_other_oserror_is_cli_error(self, _run, _which):
1936
+ result = dispatch("test", "hi", AgentConfig(directory="/tmp", timeout=10), self.settings)
1937
+ assert not result.success
1938
+ assert result.error_type == "cli_error"
@@ -3002,3 +3002,101 @@ class TestUpdateAgentLockDiscipline:
3002
3002
  saved = load_config(cfg_file)
3003
3003
  assert saved.agents["infra"].description == "from-A"
3004
3004
  assert saved.agents["db"].description == "from-B"
3005
+
3006
+
3007
+ class TestNonUtf8Config:
3008
+ """UnicodeDecodeError is a ValueError, not an OSError — it slipped past every
3009
+ handler and crashed all 21 tools (and `doctor`, the diagnosis command)."""
3010
+
3011
+ @pytest.fixture()
3012
+ def cp1251_config(self, tmp_path: Path, monkeypatch):
3013
+ cfg = tmp_path / "agents.yaml"
3014
+ cfg.write_bytes(
3015
+ 'agents:\n infra:\n directory: /tmp\n description: "Инфраструктура"\n'.encode(
3016
+ "cp1251"
3017
+ )
3018
+ )
3019
+ monkeypatch.setenv("AGENT_DISPATCH_CONFIG", str(cfg))
3020
+ return cfg
3021
+
3022
+ @pytest.mark.asyncio
3023
+ async def test_list_agents_returns_error_envelope(self, cp1251_config):
3024
+ data = json.loads(await server.list_agents(ctx=None))
3025
+ assert "could not be loaded" in data["error"]
3026
+
3027
+ @pytest.mark.asyncio
3028
+ async def test_dispatch_returns_error_envelope(self, cp1251_config):
3029
+ data = json.loads(await server.dispatch("infra", "task", ctx=None))
3030
+ assert "could not be loaded" in data["error"]
3031
+
3032
+
3033
+ class TestWriteFailureEnvelope:
3034
+ """The load half of the contract was fixed first; a failed *write* (full
3035
+ disk, read-only volume) escaped the same tools as a raw OSError."""
3036
+
3037
+ @pytest.mark.asyncio
3038
+ async def test_add_agent_reports_io_failure_as_json(self, tmp_path: Path, monkeypatch):
3039
+ project = tmp_path / "proj"
3040
+ project.mkdir()
3041
+ monkeypatch.setenv("AGENT_DISPATCH_CONFIG", str(tmp_path / "agents.yaml"))
3042
+ boom = OSError(28, "No space left on device")
3043
+ with patch.object(server, "save_config", side_effect=boom):
3044
+ raw = await server.add_agent("new", str(project))
3045
+ data = json.loads(raw)
3046
+ assert "I/O" in data["error"]
3047
+ assert "No space left" in data["error"]
3048
+ assert "hint" in data
3049
+
3050
+ @pytest.mark.asyncio
3051
+ async def test_update_agent_reports_io_failure_as_json(self, tmp_path: Path, monkeypatch):
3052
+ config = _make_config(tmp_path)
3053
+ from agent_dispatch.config import save_config as real_save
3054
+
3055
+ cfg = tmp_path / "agents.yaml"
3056
+ real_save(config, cfg)
3057
+ monkeypatch.setenv("AGENT_DISPATCH_CONFIG", str(cfg))
3058
+ with patch.object(server, "save_config", side_effect=OSError("read-only file system")):
3059
+ data = json.loads(await server.update_agent("infra", description="x"))
3060
+ assert "I/O" in data["error"]
3061
+
3062
+
3063
+ class TestJobToolsIoGuard:
3064
+ """The six job tools never load config, so @_config_guard was not applied —
3065
+ leaving their I/O failures escaping raw."""
3066
+
3067
+ @pytest.mark.asyncio
3068
+ async def test_unusable_jobs_dir_returns_envelope(self, tmp_path: Path, monkeypatch):
3069
+ blocked = tmp_path / "blocked"
3070
+ blocked.write_text("this is a file, not a directory")
3071
+ monkeypatch.setenv("AGENT_DISPATCH_JOBS_DIR", str(blocked / "jobs"))
3072
+ server._job_store = None
3073
+ data = json.loads(await server.dispatch_jobs())
3074
+ assert "I/O" in data["error"]
3075
+ assert "hint" in data
3076
+
3077
+ @pytest.mark.asyncio
3078
+ async def test_all_job_tools_are_guarded(self):
3079
+ import inspect
3080
+
3081
+ for name in (
3082
+ "dispatch_status",
3083
+ "dispatch_wait",
3084
+ "dispatch_cancel",
3085
+ "dispatch_jobs",
3086
+ "fetch_result",
3087
+ "dispatch_gc",
3088
+ ):
3089
+ fn = getattr(server, name)
3090
+ src = inspect.getsource(inspect.unwrap(fn))
3091
+ assert hasattr(fn, "__wrapped__"), f"{name} is not wrapped by a guard"
3092
+ assert src # sanity
3093
+
3094
+
3095
+ class TestAddAgentBadDirectory:
3096
+ @pytest.mark.asyncio
3097
+ async def test_unresolvable_home_returns_envelope(self, tmp_path: Path, monkeypatch):
3098
+ monkeypatch.setenv("AGENT_DISPATCH_CONFIG", str(tmp_path / "agents.yaml"))
3099
+ # expanduser raises RuntimeError for an unknown user — not a
3100
+ # directory-missing error, and it used to escape the tool.
3101
+ data = json.loads(await server.add_agent("z", "~nonexistentuser12345/proj"))
3102
+ assert "Invalid directory" in data["error"]
File without changes