agent-dispatch 0.5.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/CHANGELOG.md +58 -1
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/PKG-INFO +30 -5
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/README.md +29 -4
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/pyproject.toml +1 -1
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/cli.py +12 -2
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/jobs.py +21 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/models.py +7 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/runner.py +186 -27
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/server.py +112 -16
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_cli.py +34 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_jobs.py +42 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_runner.py +358 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_server.py +392 -15
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/.github/workflows/ci.yml +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/.github/workflows/publish.yml +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/.gitignore +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/LICENSE +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/SECURITY.md +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/agents.example.yaml +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/assets/mascot.png +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/cache.py +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/config.py +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/__init__.py +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/conftest.py +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_cache.py +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_config.py +0 -0
- {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_models.py +0 -0
|
@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-06-04
|
|
11
|
+
|
|
12
|
+
Reliability release: timeouts stop being fatal, permission-blocked "successes"
|
|
13
|
+
become visible, async jobs show live progress.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **`dispatch_stream` was broken on current claude CLIs** — they reject
|
|
17
|
+
`--print --output-format stream-json` without `--verbose` ("requires
|
|
18
|
+
--verbose"), so every stream dispatch (and CLI `test --stream`) failed
|
|
19
|
+
immediately. The runner now passes `--verbose`. Caught by live verification
|
|
20
|
+
against the real CLI before this release; without it the async-worker
|
|
21
|
+
switch to streaming (below) would have broken all `dispatch_async` jobs.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **Per-call timeout override.** `dispatch`, `dispatch_session`,
|
|
25
|
+
`dispatch_stream`, and `dispatch_async` accept `timeout_seconds` (0 = agent
|
|
26
|
+
default, clamped to 10–7200); `dispatch_parallel` accepts it per item. Use
|
|
27
|
+
it for known-long tasks instead of editing the agent config. CLI:
|
|
28
|
+
`agent-dispatch test <name> --timeout N`.
|
|
29
|
+
- **Resumable timeouts.** Fresh dispatches pre-assign a session UUID via
|
|
30
|
+
`--session-id`, so a timed-out dispatch still returns a `session_id` — the
|
|
31
|
+
partial transcript survives the kill. The timeout error now spells out the
|
|
32
|
+
recovery options: resume via `dispatch_session(..., session_id=...)`, retry
|
|
33
|
+
with `timeout_seconds`, or go async.
|
|
34
|
+
- **Denied-tools visibility.** The claude CLI's `permission_denials` output is
|
|
35
|
+
parsed into `DispatchResult.denied_tools`. A dispatch that "succeeds" while
|
|
36
|
+
tools were blocked (the agent answers "I need permission for X") now carries
|
|
37
|
+
`denied_tools` + a `hint` that the result may be incomplete and how to grant
|
|
38
|
+
access. On `is_error` results, non-empty denials force
|
|
39
|
+
`error_type="permission"` even when the error text has no permission
|
|
40
|
+
keywords. CLI `test` prints the hint as a yellow note.
|
|
41
|
+
- **Async job progress.** Async workers now run with streaming: the job file
|
|
42
|
+
keeps a rolling tail (last 20 lines, throttled to ~1 write/sec) of assistant
|
|
43
|
+
text and tool-use events. `dispatch_status` returns it as `progress` while
|
|
44
|
+
running (kept afterwards as a post-mortem trace); `dispatch_jobs` shows
|
|
45
|
+
`last_progress` for running jobs. New `JobStore.update_progress` (refuses
|
|
46
|
+
terminal jobs, so a trailing write can't resurrect a finished job).
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
- Timeout error messages are actionable (mention `timeout_seconds`,
|
|
50
|
+
`dispatch_async`, `agent-dispatch update --timeout`, and the resumable
|
|
51
|
+
session) instead of just "increase timeout in agents.yaml".
|
|
52
|
+
- Plain-text fallback successes now carry the generated `session_id`; the
|
|
53
|
+
stream "no result line" fallback does too (a crash mid-stream stays
|
|
54
|
+
resumable).
|
|
55
|
+
- **Old-CLI self-healing**: if the installed claude CLI predates
|
|
56
|
+
`--session-id`, dispatch detects the "unknown option" rejection and retries
|
|
57
|
+
once without the flag (logged warning; timed-out dispatches lose
|
|
58
|
+
resumability) instead of failing every dispatch.
|
|
59
|
+
- `dispatch_parallel` validates per-item `timeout_seconds` / `summary_chars`
|
|
60
|
+
numerically **up front** — a bad value rejects the whole call before any
|
|
61
|
+
dispatch runs, consistent with the structural validation contract.
|
|
62
|
+
- `denied_tools` parsing is bounded (10 entries, 100 chars per name) — the
|
|
63
|
+
field comes from the dispatched subprocess's output, which is untrusted;
|
|
64
|
+
unbounded lists could inflate job files and `return_ref` payloads.
|
|
65
|
+
|
|
10
66
|
## [0.5.0] - 2026-06-01
|
|
11
67
|
|
|
12
68
|
Security-hardening release. A multi-agent audit of the codebase surfaced
|
|
@@ -214,7 +270,8 @@ cache bounding, and stale-job recovery.
|
|
|
214
270
|
- Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
|
|
215
271
|
commit SHAs for supply-chain integrity.
|
|
216
272
|
|
|
217
|
-
[Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.
|
|
273
|
+
[Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.6.0...HEAD
|
|
274
|
+
[0.6.0]: https://github.com/ginkida/agent-dispatch/compare/v0.5.0...v0.6.0
|
|
218
275
|
[0.5.0]: https://github.com/ginkida/agent-dispatch/compare/v0.4.0...v0.5.0
|
|
219
276
|
[0.4.0]: https://github.com/ginkida/agent-dispatch/compare/v0.3.0...v0.4.0
|
|
220
277
|
[0.3.0]: https://github.com/ginkida/agent-dispatch/compare/v0.2.2...v0.3.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-dispatch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: MCP server that lets Claude Code agents delegate tasks to agents in other project directories
|
|
5
5
|
Project-URL: Homepage, https://github.com/ginkida/agent-dispatch
|
|
6
6
|
Project-URL: Repository, https://github.com/ginkida/agent-dispatch
|
|
@@ -130,6 +130,7 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
130
130
|
| `response_format` | string | no | `"json"` to request a single JSON value; the parsed result lands in `parsed_result`. Empty = free-form text. |
|
|
131
131
|
| `return_ref` | bool | no | When `true`, returns just a `ref` + summary preview instead of the full result text. Use `fetch_result(ref)` to load the full text on demand. |
|
|
132
132
|
| `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
|
|
133
|
+
| `timeout_seconds` | int | no | One-off timeout override for this call (0 = agent's configured timeout; clamped to 10–7200). No config edit needed for known-long tasks. |
|
|
133
134
|
|
|
134
135
|
```json
|
|
135
136
|
// Response (success)
|
|
@@ -155,6 +156,21 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
155
156
|
|
|
156
157
|
**`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
|
|
157
158
|
|
|
159
|
+
**Resumable timeouts:** every fresh dispatch pre-assigns a session UUID (`--session-id`), so a timed-out dispatch still returns a `session_id` — the partial transcript survives the kill. The timeout error spells out the recovery: resume with `dispatch_session(agent, "Continue where you left off", session_id=...)`, retry with a bigger `timeout_seconds`, or use `dispatch_async`.
|
|
160
|
+
|
|
161
|
+
**Denied-tools visibility:** in non-interactive mode the claude CLI auto-denies tools the agent isn't allowed to use — the agent then often "succeeds" with an answer like *"I need your permission for one read-only query"*. When that happens the response carries the deterministic signal: `denied_tools` (parsed from the CLI's `permission_denials`) plus a `hint` explaining the result may be incomplete and how to grant access. `success` stays `true` — it's a soft signal, not a failure.
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
// Response (success, but a tool was blocked)
|
|
165
|
+
{
|
|
166
|
+
"agent": "analysis",
|
|
167
|
+
"success": true,
|
|
168
|
+
"result": "Here is the offline mapping. To finish I'd need to run one read-only query...",
|
|
169
|
+
"denied_tools": ["Bash"],
|
|
170
|
+
"hint": "1 tool call(s) were denied by permissions: Bash. The result may be incomplete..."
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
158
174
|
**Structured JSON output:** pass `response_format="json"` to ask the agent for a single JSON value. The runner appends an instruction footer ("respond with a single valid JSON value, no fences, no prose") and on success parses the response — the parsed value lands in `parsed_result`. The raw text is always in `result`. Parse failures leave `parsed_result=None` but don't fail the dispatch (soft mode).
|
|
159
175
|
|
|
160
176
|
```json
|
|
@@ -195,6 +211,9 @@ Multi-turn: continue a conversation with an agent. First call starts a session,
|
|
|
195
211
|
| `context` | string | no | Extra context |
|
|
196
212
|
| `caller` | string | no | Who is dispatching |
|
|
197
213
|
| `goal` | string | no | Broader objective |
|
|
214
|
+
| `timeout_seconds` | int | no | One-off timeout override (0 = agent default; clamped to 10–7200) |
|
|
215
|
+
|
|
216
|
+
`dispatch_session` is also the **timeout recovery path**: a timed-out `dispatch` returns a `session_id` — pass it here with `task="Continue where you left off"` to salvage the partial work instead of restarting.
|
|
198
217
|
|
|
199
218
|
```
|
|
200
219
|
Turn 1: dispatch_session("infra", "List running containers")
|
|
@@ -210,7 +229,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
|
|
|
210
229
|
|
|
211
230
|
| Parameter | Type | Required | Description |
|
|
212
231
|
|-----------|------|----------|-------------|
|
|
213
|
-
| `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?"}` |
|
|
232
|
+
| `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?", "response_format?", "return_ref?", "summary_chars?", "timeout_seconds?"}` |
|
|
214
233
|
| `aggregate` | string | no | Agent name to synthesize all results into one answer |
|
|
215
234
|
|
|
216
235
|
**Important:** `dispatches` is a JSON string, not a list.
|
|
@@ -250,7 +269,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
|
|
|
250
269
|
|
|
251
270
|
Same as `dispatch` but shows live progress while the agent works. Use for long-running tasks. Not cached.
|
|
252
271
|
|
|
253
|
-
Parameters are
|
|
272
|
+
Parameters are the same as `dispatch` except `return_ref`/`summary_chars` (streaming is incompatible with ref-mode).
|
|
254
273
|
|
|
255
274
|
### `dispatch_dialogue`
|
|
256
275
|
|
|
@@ -344,13 +363,15 @@ Refs reuse the same storage as `dispatch_async` jobs (under `~/.config/agent-dis
|
|
|
344
363
|
When a dispatched task is going to take a while, you don't want to block your own tool slot for minutes. Async dispatch returns a `job_id` immediately and lets you check back when you're ready.
|
|
345
364
|
|
|
346
365
|
```
|
|
347
|
-
// 1. fire and forget
|
|
366
|
+
// 1. fire and forget (timeout_seconds= works here too for known-long tasks)
|
|
348
367
|
dispatch_async(agent="infra", task="audit every container log for OOM kills today")
|
|
349
368
|
-> {"job_id": "8f3a...e1", "status": "pending", "agent": "infra"}
|
|
350
369
|
|
|
351
370
|
// 2. do other work, then check progress (non-blocking)
|
|
371
|
+
// `progress` is a rolling tail of what the agent is doing right now
|
|
352
372
|
dispatch_status(job_id="8f3a...e1")
|
|
353
|
-
-> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4,
|
|
373
|
+
-> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4,
|
|
374
|
+
"progress": ["Using tool: Bash", "Scanning container logs for OOM events..."], ...}
|
|
354
375
|
|
|
355
376
|
// 3. or block until done (with a timeout cap)
|
|
356
377
|
dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
|
|
@@ -362,6 +383,8 @@ dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
|
|
|
362
383
|
|
|
363
384
|
`dispatch_cancel(job_id)` cancels a job that is still **pending** (before its subprocess starts) — a running job is left to finish, since its `claude` subprocess can't be safely interrupted. The response carries an `outcome` of `cancelled`, `running`, `already_terminal`, or `not_found`.
|
|
364
385
|
|
|
386
|
+
Async workers run with streaming under the hood: the job file keeps a rolling tail (last 20 lines, ~1 write/sec) of assistant text and tool-use events. `dispatch_status` shows it as `progress` while the job runs and keeps it afterwards as a post-mortem trace; `dispatch_jobs` shows `last_progress` for running jobs.
|
|
387
|
+
|
|
365
388
|
`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.
|
|
366
389
|
|
|
367
390
|
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 abandoned in `running` by a prior crashed instance as `failed`.
|
|
@@ -392,6 +415,8 @@ All tools return errors as:
|
|
|
392
415
|
| Need a combined summary from multiple agents | `dispatch_parallel` with `aggregate` |
|
|
393
416
|
| Long task — don't block your tool slot | `dispatch_async` + `dispatch_wait` |
|
|
394
417
|
| Check progress without blocking | `dispatch_status` |
|
|
418
|
+
| Known-long task, one-off | any dispatch tool with `timeout_seconds=...` |
|
|
419
|
+
| A dispatch timed out | `dispatch_session` with the `session_id` from the error |
|
|
395
420
|
|
|
396
421
|
## Configuration
|
|
397
422
|
|
|
@@ -100,6 +100,7 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
100
100
|
| `response_format` | string | no | `"json"` to request a single JSON value; the parsed result lands in `parsed_result`. Empty = free-form text. |
|
|
101
101
|
| `return_ref` | bool | no | When `true`, returns just a `ref` + summary preview instead of the full result text. Use `fetch_result(ref)` to load the full text on demand. |
|
|
102
102
|
| `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
|
|
103
|
+
| `timeout_seconds` | int | no | One-off timeout override for this call (0 = agent's configured timeout; clamped to 10–7200). No config edit needed for known-long tasks. |
|
|
103
104
|
|
|
104
105
|
```json
|
|
105
106
|
// Response (success)
|
|
@@ -125,6 +126,21 @@ One-shot task delegation. Results are cached — identical requests within TTL r
|
|
|
125
126
|
|
|
126
127
|
**`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
|
|
127
128
|
|
|
129
|
+
**Resumable timeouts:** every fresh dispatch pre-assigns a session UUID (`--session-id`), so a timed-out dispatch still returns a `session_id` — the partial transcript survives the kill. The timeout error spells out the recovery: resume with `dispatch_session(agent, "Continue where you left off", session_id=...)`, retry with a bigger `timeout_seconds`, or use `dispatch_async`.
|
|
130
|
+
|
|
131
|
+
**Denied-tools visibility:** in non-interactive mode the claude CLI auto-denies tools the agent isn't allowed to use — the agent then often "succeeds" with an answer like *"I need your permission for one read-only query"*. When that happens the response carries the deterministic signal: `denied_tools` (parsed from the CLI's `permission_denials`) plus a `hint` explaining the result may be incomplete and how to grant access. `success` stays `true` — it's a soft signal, not a failure.
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
// Response (success, but a tool was blocked)
|
|
135
|
+
{
|
|
136
|
+
"agent": "analysis",
|
|
137
|
+
"success": true,
|
|
138
|
+
"result": "Here is the offline mapping. To finish I'd need to run one read-only query...",
|
|
139
|
+
"denied_tools": ["Bash"],
|
|
140
|
+
"hint": "1 tool call(s) were denied by permissions: Bash. The result may be incomplete..."
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
128
144
|
**Structured JSON output:** pass `response_format="json"` to ask the agent for a single JSON value. The runner appends an instruction footer ("respond with a single valid JSON value, no fences, no prose") and on success parses the response — the parsed value lands in `parsed_result`. The raw text is always in `result`. Parse failures leave `parsed_result=None` but don't fail the dispatch (soft mode).
|
|
129
145
|
|
|
130
146
|
```json
|
|
@@ -165,6 +181,9 @@ Multi-turn: continue a conversation with an agent. First call starts a session,
|
|
|
165
181
|
| `context` | string | no | Extra context |
|
|
166
182
|
| `caller` | string | no | Who is dispatching |
|
|
167
183
|
| `goal` | string | no | Broader objective |
|
|
184
|
+
| `timeout_seconds` | int | no | One-off timeout override (0 = agent default; clamped to 10–7200) |
|
|
185
|
+
|
|
186
|
+
`dispatch_session` is also the **timeout recovery path**: a timed-out `dispatch` returns a `session_id` — pass it here with `task="Continue where you left off"` to salvage the partial work instead of restarting.
|
|
168
187
|
|
|
169
188
|
```
|
|
170
189
|
Turn 1: dispatch_session("infra", "List running containers")
|
|
@@ -180,7 +199,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
|
|
|
180
199
|
|
|
181
200
|
| Parameter | Type | Required | Description |
|
|
182
201
|
|-----------|------|----------|-------------|
|
|
183
|
-
| `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?"}` |
|
|
202
|
+
| `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?", "response_format?", "return_ref?", "summary_chars?", "timeout_seconds?"}` |
|
|
184
203
|
| `aggregate` | string | no | Agent name to synthesize all results into one answer |
|
|
185
204
|
|
|
186
205
|
**Important:** `dispatches` is a JSON string, not a list.
|
|
@@ -220,7 +239,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
|
|
|
220
239
|
|
|
221
240
|
Same as `dispatch` but shows live progress while the agent works. Use for long-running tasks. Not cached.
|
|
222
241
|
|
|
223
|
-
Parameters are
|
|
242
|
+
Parameters are the same as `dispatch` except `return_ref`/`summary_chars` (streaming is incompatible with ref-mode).
|
|
224
243
|
|
|
225
244
|
### `dispatch_dialogue`
|
|
226
245
|
|
|
@@ -314,13 +333,15 @@ Refs reuse the same storage as `dispatch_async` jobs (under `~/.config/agent-dis
|
|
|
314
333
|
When a dispatched task is going to take a while, you don't want to block your own tool slot for minutes. Async dispatch returns a `job_id` immediately and lets you check back when you're ready.
|
|
315
334
|
|
|
316
335
|
```
|
|
317
|
-
// 1. fire and forget
|
|
336
|
+
// 1. fire and forget (timeout_seconds= works here too for known-long tasks)
|
|
318
337
|
dispatch_async(agent="infra", task="audit every container log for OOM kills today")
|
|
319
338
|
-> {"job_id": "8f3a...e1", "status": "pending", "agent": "infra"}
|
|
320
339
|
|
|
321
340
|
// 2. do other work, then check progress (non-blocking)
|
|
341
|
+
// `progress` is a rolling tail of what the agent is doing right now
|
|
322
342
|
dispatch_status(job_id="8f3a...e1")
|
|
323
|
-
-> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4,
|
|
343
|
+
-> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4,
|
|
344
|
+
"progress": ["Using tool: Bash", "Scanning container logs for OOM events..."], ...}
|
|
324
345
|
|
|
325
346
|
// 3. or block until done (with a timeout cap)
|
|
326
347
|
dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
|
|
@@ -332,6 +353,8 @@ dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
|
|
|
332
353
|
|
|
333
354
|
`dispatch_cancel(job_id)` cancels a job that is still **pending** (before its subprocess starts) — a running job is left to finish, since its `claude` subprocess can't be safely interrupted. The response carries an `outcome` of `cancelled`, `running`, `already_terminal`, or `not_found`.
|
|
334
355
|
|
|
356
|
+
Async workers run with streaming under the hood: the job file keeps a rolling tail (last 20 lines, ~1 write/sec) of assistant text and tool-use events. `dispatch_status` shows it as `progress` while the job runs and keeps it afterwards as a post-mortem trace; `dispatch_jobs` shows `last_progress` for running jobs.
|
|
357
|
+
|
|
335
358
|
`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.
|
|
336
359
|
|
|
337
360
|
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 abandoned in `running` by a prior crashed instance as `failed`.
|
|
@@ -362,6 +385,8 @@ All tools return errors as:
|
|
|
362
385
|
| Need a combined summary from multiple agents | `dispatch_parallel` with `aggregate` |
|
|
363
386
|
| Long task — don't block your tool slot | `dispatch_async` + `dispatch_wait` |
|
|
364
387
|
| Check progress without blocking | `dispatch_status` |
|
|
388
|
+
| Known-long task, one-off | any dispatch tool with `timeout_seconds=...` |
|
|
389
|
+
| A dispatch timed out | `dispatch_session` with the `session_id` from the error |
|
|
365
390
|
|
|
366
391
|
## Configuration
|
|
367
392
|
|
|
@@ -303,7 +303,11 @@ def update(
|
|
|
303
303
|
"--stream", "stream", is_flag=True,
|
|
304
304
|
help="Show live progress (assistant text + tool use) while the agent works.",
|
|
305
305
|
)
|
|
306
|
-
|
|
306
|
+
@click.option(
|
|
307
|
+
"--timeout", "timeout", default=None, type=int,
|
|
308
|
+
help="One-off timeout override in seconds (does not change the agent config).",
|
|
309
|
+
)
|
|
310
|
+
def test(name: str, task: str, stream: bool, timeout: int | None) -> None:
|
|
307
311
|
"""Test an agent by dispatching a task."""
|
|
308
312
|
config = _load_or_exit()
|
|
309
313
|
if name not in config.agents:
|
|
@@ -311,6 +315,8 @@ def test(name: str, task: str, stream: bool) -> None:
|
|
|
311
315
|
raise SystemExit(1)
|
|
312
316
|
|
|
313
317
|
agent = config.agents[name]
|
|
318
|
+
if timeout is not None and timeout > 0:
|
|
319
|
+
agent = agent.model_copy(update={"timeout": timeout})
|
|
314
320
|
click.echo(f"Dispatching to '{name}' ({agent.directory})...")
|
|
315
321
|
click.echo(f"Task: {task}")
|
|
316
322
|
click.echo("---")
|
|
@@ -330,6 +336,9 @@ def test(name: str, task: str, stream: bool) -> None:
|
|
|
330
336
|
|
|
331
337
|
if result.success:
|
|
332
338
|
click.echo(result.result)
|
|
339
|
+
if result.hint:
|
|
340
|
+
click.echo()
|
|
341
|
+
click.echo(click.style(f"Note: {result.hint}", fg="yellow"))
|
|
333
342
|
if result.cost_usd is not None:
|
|
334
343
|
click.echo(f"\n--- Cost: ${result.cost_usd:.4f} | Turns: {result.num_turns}")
|
|
335
344
|
else:
|
|
@@ -343,7 +352,8 @@ def test(name: str, task: str, stream: bool) -> None:
|
|
|
343
352
|
elif result.error_type == "timeout":
|
|
344
353
|
click.echo()
|
|
345
354
|
click.echo(click.style("Diagnosis: timeout", fg="yellow"))
|
|
346
|
-
click.echo(f" agent-dispatch
|
|
355
|
+
click.echo(f" agent-dispatch test {name} --timeout 600 # one-off")
|
|
356
|
+
click.echo(f" agent-dispatch update {name} --timeout 600 # permanent")
|
|
347
357
|
raise SystemExit(1)
|
|
348
358
|
|
|
349
359
|
|
|
@@ -59,6 +59,11 @@ class Job(BaseModel):
|
|
|
59
59
|
completed_at: float | None = None
|
|
60
60
|
result: DispatchResult | None = None
|
|
61
61
|
error: str | None = None
|
|
62
|
+
# Rolling tail of progress lines (assistant text / tool-use events) from
|
|
63
|
+
# the worker's streaming dispatch. None until the first event arrives.
|
|
64
|
+
# Kept after completion as a post-mortem trace of what the agent did.
|
|
65
|
+
progress: list[str] | None = None
|
|
66
|
+
progress_updated_at: float | None = None
|
|
62
67
|
|
|
63
68
|
def is_terminal(self) -> bool:
|
|
64
69
|
return self.status in _TERMINAL_STATUSES
|
|
@@ -194,6 +199,22 @@ class JobStore:
|
|
|
194
199
|
self._write(job)
|
|
195
200
|
return job
|
|
196
201
|
|
|
202
|
+
def update_progress(self, job_id: str, lines: list[str]) -> Job | None:
|
|
203
|
+
"""Replace a running job's progress tail. No-op for terminal jobs.
|
|
204
|
+
|
|
205
|
+
Returns the updated job, or None if missing/terminal (a worker's
|
|
206
|
+
trailing progress write can race a concurrent cancel/finish — refusing
|
|
207
|
+
to touch terminal jobs keeps their final state authoritative).
|
|
208
|
+
"""
|
|
209
|
+
with self._lock:
|
|
210
|
+
job = self.get(job_id)
|
|
211
|
+
if job is None or job.is_terminal():
|
|
212
|
+
return None
|
|
213
|
+
job.progress = lines
|
|
214
|
+
job.progress_updated_at = time.time()
|
|
215
|
+
self._write(job)
|
|
216
|
+
return job
|
|
217
|
+
|
|
197
218
|
def cancel(self, job_id: str) -> tuple[Job | None, str]:
|
|
198
219
|
"""Attempt to cancel a job.
|
|
199
220
|
|
|
@@ -113,3 +113,10 @@ class DispatchResult(BaseModel):
|
|
|
113
113
|
# Set when response_format="json" was requested AND the agent's result
|
|
114
114
|
# parsed cleanly. None means: not requested, or requested but unparseable.
|
|
115
115
|
parsed_result: Any | None = None
|
|
116
|
+
# Tools the claude CLI refused to run (from `permission_denials` in its
|
|
117
|
+
# JSON output). Non-empty even on success=True — the agent may have
|
|
118
|
+
# completed with an incomplete answer because a tool was blocked.
|
|
119
|
+
denied_tools: list[str] | None = None
|
|
120
|
+
# Advisory, non-fatal guidance (e.g. "result may be incomplete, grant X").
|
|
121
|
+
# Errors stay in `error`; hint is for successful-but-degraded results.
|
|
122
|
+
hint: str | None = None
|