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.
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/AGENTS.md +3 -3
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/CHANGELOG.md +47 -1
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/PKG-INFO +6 -6
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/README.md +5 -5
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/pyproject.toml +1 -1
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/cli.py +45 -6
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/config.py +9 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/runner.py +28 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/server.py +53 -8
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_cli.py +58 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_runner.py +70 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_server.py +98 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/.github/workflows/ci.yml +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/.github/workflows/publish.yml +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/.gitignore +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/LICENSE +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/SECURITY.md +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/agents.example.yaml +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/assets/mascot.png +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/cache.py +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/jobs.py +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/src/agent_dispatch/models.py +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/__init__.py +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/conftest.py +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_cache.py +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_config.py +0 -0
- {agent_dispatch-0.12.0 → agent_dispatch-0.12.1}/tests/test_jobs.py +0 -0
- {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 #
|
|
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/`,
|
|
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.
|
|
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.
|
|
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 =
|
|
392
|
-
| `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = no
|
|
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
|
|
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
|
|
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),
|
|
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 =
|
|
362
|
-
| `max_budget_usd` | float | no | Max cost in USD per dispatch (0 = no
|
|
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
|
|
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
|
|
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),
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|