pilot-workers 0.2.0__tar.gz → 0.4.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.
- pilot_workers-0.4.0/PKG-INFO +129 -0
- pilot_workers-0.4.0/README.md +114 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/pyproject.toml +1 -1
- pilot_workers-0.4.0/src/pilot_workers/cli/fanout.py +436 -0
- pilot_workers-0.4.0/src/pilot_workers/cli/install.py +520 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/main.py +25 -11
- pilot_workers-0.4.0/src/pilot_workers/cli/status.py +178 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/credentials.py +7 -21
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/kimi-k3.yaml +1 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/fmt_events.py +0 -1
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/maintain.py +5 -2
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/policy.py +2 -2
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/providers.py +26 -3
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/__init__.py +1 -1
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/base.py +2 -2
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/opencode_runner.py +22 -37
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/runtime.py +8 -6
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/scripts/install_runtime.sh +1 -2
- pilot_workers-0.4.0/src/pilot_workers.egg-info/PKG-INFO +129 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/SOURCES.txt +5 -1
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_cli_main.py +22 -6
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_dispatch.py +12 -0
- pilot_workers-0.4.0/tests/test_fanout.py +346 -0
- pilot_workers-0.4.0/tests/test_install.py +366 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_opencode_runner.py +1 -1
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_providers.py +16 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_render_unified.py +27 -1
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_runtime.py +22 -2
- pilot_workers-0.4.0/tests/test_status.py +123 -0
- pilot_workers-0.2.0/PKG-INFO +0 -84
- pilot_workers-0.2.0/README.md +0 -69
- pilot_workers-0.2.0/src/pilot_workers/cli/install.py +0 -259
- pilot_workers-0.2.0/src/pilot_workers.egg-info/PKG-INFO +0 -84
- pilot_workers-0.2.0/tests/test_install.py +0 -126
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/LICENSE +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/setup.cfg +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/__init__.py +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/__main__.py +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/__init__.py +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/dispatch.py +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/run.py +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/README.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/relaxed.yaml +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/strict.yaml +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/README.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/ds.yaml +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/glm.yaml +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/code.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/explore.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/review.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/test.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/README.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-coder.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-explorer.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-reviewer.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-tester.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-coder.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-explorer.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-reviewer.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-tester.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-coder.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-explorer.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-reviewer.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-tester.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/code.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/explore.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/review.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/test.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/code.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/explore.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/review.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/test.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/SKILL.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/openai.yaml +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/glm/SKILL.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/glm/openai.yaml +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/kimi/SKILL.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/kimi/openai.yaml +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/code.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/common.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/explore.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/review.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/test.md +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/dependency_links.txt +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/entry_points.txt +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/requires.txt +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/top_level.txt +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_credentials.py +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_policy.py +0 -0
- {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_run_cli.py +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pilot-workers
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Dispatch bounded tasks to isolated LLM workers (GLM, Kimi, and more)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: yaml
|
|
10
|
+
Requires-Dist: pyyaml>=6.0; extra == "yaml"
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pyyaml>=6.0; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# pilot-workers
|
|
17
|
+
|
|
18
|
+
Dispatch bounded tasks to isolated LLM workers. Your main AI agent (Claude, Codex, or any planner) stays in control of requirements, planning, and verification — the worker only executes what it's told.
|
|
19
|
+
|
|
20
|
+
## What it does
|
|
21
|
+
|
|
22
|
+
- **Provider isolation**: each model (GLM, Kimi, DeepSeek, or your own) gets its own credentials, XDG directories, logs, and session storage. No cross-contamination.
|
|
23
|
+
- **Fixed routing**: provider, model, and endpoint are locked per YAML config. Tasks cannot override them.
|
|
24
|
+
- **Security by default**: API keys never appear in CLI args, environment variables, task contracts, or logs. Output is auto-redacted.
|
|
25
|
+
- **Five modes**: `code` (edit), `explore` (read-only), `test` (run tests), `review` (read-only audit), `resume` (continue a prior code session).
|
|
26
|
+
- **Pluggable runners**: the runner adapter layer (`Runner` ABC) abstracts engine-specific details. Currently ships with OpenCode; designed for future alternatives.
|
|
27
|
+
- **Observable**: two-line JSON contract (`started` + `verdict`) for AI planners; human-readable `latest.log` for `tail -f`.
|
|
28
|
+
- **Per-provider installs**: `install glm on claude` deploys only GLM's integration to Claude Code. Mix and match providers × hosts.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install pilot-workers
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# 1. Install the worker runtime
|
|
40
|
+
pilot-workers install runner opencode
|
|
41
|
+
|
|
42
|
+
# 2. Configure credentials (interactive, key never displayed)
|
|
43
|
+
pilot-workers credentials glm
|
|
44
|
+
pilot-workers credentials kimi-k3
|
|
45
|
+
pilot-workers credentials ds
|
|
46
|
+
|
|
47
|
+
# 3. Deploy integrations to your host
|
|
48
|
+
pilot-workers install all on claude # Claude Code: 12 agents + 8 slash commands
|
|
49
|
+
pilot-workers install all on codex # Codex: $glm / $kimi / $ds skills
|
|
50
|
+
pilot-workers install glm on claude # or just one provider on one host
|
|
51
|
+
|
|
52
|
+
# 4. Check everything is ready
|
|
53
|
+
pilot-workers status
|
|
54
|
+
|
|
55
|
+
# 5. Verify with a dry-run
|
|
56
|
+
pilot-workers run --provider glm --mode explore --workdir . --task "hello" --dry-run
|
|
57
|
+
|
|
58
|
+
# 6. Run a real task
|
|
59
|
+
pilot-workers template code > /tmp/task.md # generate a structured task template
|
|
60
|
+
# fill in the template, then:
|
|
61
|
+
pilot-workers dispatch --provider glm --mode code --workdir /path/to/project --task-file /tmp/task.md
|
|
62
|
+
# dispatch stdout = exactly two JSON lines: worker_runner.started + worker_runner.verdict
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## CLI reference
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
pilot-workers <subcommand> [args]
|
|
69
|
+
|
|
70
|
+
run Dispatch a task (streaming output).
|
|
71
|
+
dispatch Deterministic wrapper around run (two-line JSON: started + verdict).
|
|
72
|
+
fanout Dispatch several jobs concurrently; stdout = one JSON array of verdicts.
|
|
73
|
+
template Print the task template for a mode (code|explore|test|review).
|
|
74
|
+
install Install integrations or runner.
|
|
75
|
+
install <provider|all> on <host|all>
|
|
76
|
+
install runner <name>
|
|
77
|
+
uninstall Remove integrations or runner.
|
|
78
|
+
uninstall <provider|all> on <host|all>
|
|
79
|
+
uninstall runner <name>
|
|
80
|
+
status Show provider credentials, host installs, and runner state.
|
|
81
|
+
status [--json]
|
|
82
|
+
status <provider> on <host>
|
|
83
|
+
credentials Configure isolated worker credentials.
|
|
84
|
+
maintain Worker log and worktree lifecycle tools.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Adding a new provider
|
|
88
|
+
|
|
89
|
+
Drop a YAML file in `data/providers/` (inside the package):
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
key: my-model
|
|
93
|
+
provider_id: my-worker
|
|
94
|
+
model_id: my-model-v1
|
|
95
|
+
base_url: https://api.example.com/v1
|
|
96
|
+
display_name: My Model Worker
|
|
97
|
+
context_tokens: 128000
|
|
98
|
+
output_tokens: 8192
|
|
99
|
+
# runner: opencode # optional, default opencode
|
|
100
|
+
# permissions: relaxed # optional, reference a permission profile
|
|
101
|
+
# asset_prefix: my-model # optional, default = key; used for integration file naming
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Then `pilot-workers credentials my-model` and `pilot-workers install my-model on claude`.
|
|
105
|
+
|
|
106
|
+
Reserved keys (cannot be used as provider key): `runner`, `all`, `on`, `claude`, `codex`.
|
|
107
|
+
|
|
108
|
+
## Host integration
|
|
109
|
+
|
|
110
|
+
The **host** is whatever AI agent acts as the planner. `integrations/` has ready-made configs:
|
|
111
|
+
- **`claude-host/`**: 12 agents (glm/kimi/ds × coder/explorer/reviewer/tester) + 8 slash commands
|
|
112
|
+
- **`codex-host/`**: `$glm` / `$kimi` / `$ds` skill entry points
|
|
113
|
+
|
|
114
|
+
Adding a new host: create `integrations/<name>-host/`, put whatever config your host needs, point it at `pilot-workers dispatch`. See `integrations/README.md`.
|
|
115
|
+
|
|
116
|
+
## Architecture
|
|
117
|
+
|
|
118
|
+
See [CLAUDE.md](CLAUDE.md) for the current architecture, module reference, and conventions. See [docs/architecture.md](docs/architecture.md) for the detailed contract and security model.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
124
|
+
.venv/bin/pytest # 130 tests, all offline
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# pilot-workers
|
|
2
|
+
|
|
3
|
+
Dispatch bounded tasks to isolated LLM workers. Your main AI agent (Claude, Codex, or any planner) stays in control of requirements, planning, and verification — the worker only executes what it's told.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- **Provider isolation**: each model (GLM, Kimi, DeepSeek, or your own) gets its own credentials, XDG directories, logs, and session storage. No cross-contamination.
|
|
8
|
+
- **Fixed routing**: provider, model, and endpoint are locked per YAML config. Tasks cannot override them.
|
|
9
|
+
- **Security by default**: API keys never appear in CLI args, environment variables, task contracts, or logs. Output is auto-redacted.
|
|
10
|
+
- **Five modes**: `code` (edit), `explore` (read-only), `test` (run tests), `review` (read-only audit), `resume` (continue a prior code session).
|
|
11
|
+
- **Pluggable runners**: the runner adapter layer (`Runner` ABC) abstracts engine-specific details. Currently ships with OpenCode; designed for future alternatives.
|
|
12
|
+
- **Observable**: two-line JSON contract (`started` + `verdict`) for AI planners; human-readable `latest.log` for `tail -f`.
|
|
13
|
+
- **Per-provider installs**: `install glm on claude` deploys only GLM's integration to Claude Code. Mix and match providers × hosts.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install pilot-workers
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# 1. Install the worker runtime
|
|
25
|
+
pilot-workers install runner opencode
|
|
26
|
+
|
|
27
|
+
# 2. Configure credentials (interactive, key never displayed)
|
|
28
|
+
pilot-workers credentials glm
|
|
29
|
+
pilot-workers credentials kimi-k3
|
|
30
|
+
pilot-workers credentials ds
|
|
31
|
+
|
|
32
|
+
# 3. Deploy integrations to your host
|
|
33
|
+
pilot-workers install all on claude # Claude Code: 12 agents + 8 slash commands
|
|
34
|
+
pilot-workers install all on codex # Codex: $glm / $kimi / $ds skills
|
|
35
|
+
pilot-workers install glm on claude # or just one provider on one host
|
|
36
|
+
|
|
37
|
+
# 4. Check everything is ready
|
|
38
|
+
pilot-workers status
|
|
39
|
+
|
|
40
|
+
# 5. Verify with a dry-run
|
|
41
|
+
pilot-workers run --provider glm --mode explore --workdir . --task "hello" --dry-run
|
|
42
|
+
|
|
43
|
+
# 6. Run a real task
|
|
44
|
+
pilot-workers template code > /tmp/task.md # generate a structured task template
|
|
45
|
+
# fill in the template, then:
|
|
46
|
+
pilot-workers dispatch --provider glm --mode code --workdir /path/to/project --task-file /tmp/task.md
|
|
47
|
+
# dispatch stdout = exactly two JSON lines: worker_runner.started + worker_runner.verdict
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## CLI reference
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
pilot-workers <subcommand> [args]
|
|
54
|
+
|
|
55
|
+
run Dispatch a task (streaming output).
|
|
56
|
+
dispatch Deterministic wrapper around run (two-line JSON: started + verdict).
|
|
57
|
+
fanout Dispatch several jobs concurrently; stdout = one JSON array of verdicts.
|
|
58
|
+
template Print the task template for a mode (code|explore|test|review).
|
|
59
|
+
install Install integrations or runner.
|
|
60
|
+
install <provider|all> on <host|all>
|
|
61
|
+
install runner <name>
|
|
62
|
+
uninstall Remove integrations or runner.
|
|
63
|
+
uninstall <provider|all> on <host|all>
|
|
64
|
+
uninstall runner <name>
|
|
65
|
+
status Show provider credentials, host installs, and runner state.
|
|
66
|
+
status [--json]
|
|
67
|
+
status <provider> on <host>
|
|
68
|
+
credentials Configure isolated worker credentials.
|
|
69
|
+
maintain Worker log and worktree lifecycle tools.
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Adding a new provider
|
|
73
|
+
|
|
74
|
+
Drop a YAML file in `data/providers/` (inside the package):
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
key: my-model
|
|
78
|
+
provider_id: my-worker
|
|
79
|
+
model_id: my-model-v1
|
|
80
|
+
base_url: https://api.example.com/v1
|
|
81
|
+
display_name: My Model Worker
|
|
82
|
+
context_tokens: 128000
|
|
83
|
+
output_tokens: 8192
|
|
84
|
+
# runner: opencode # optional, default opencode
|
|
85
|
+
# permissions: relaxed # optional, reference a permission profile
|
|
86
|
+
# asset_prefix: my-model # optional, default = key; used for integration file naming
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Then `pilot-workers credentials my-model` and `pilot-workers install my-model on claude`.
|
|
90
|
+
|
|
91
|
+
Reserved keys (cannot be used as provider key): `runner`, `all`, `on`, `claude`, `codex`.
|
|
92
|
+
|
|
93
|
+
## Host integration
|
|
94
|
+
|
|
95
|
+
The **host** is whatever AI agent acts as the planner. `integrations/` has ready-made configs:
|
|
96
|
+
- **`claude-host/`**: 12 agents (glm/kimi/ds × coder/explorer/reviewer/tester) + 8 slash commands
|
|
97
|
+
- **`codex-host/`**: `$glm` / `$kimi` / `$ds` skill entry points
|
|
98
|
+
|
|
99
|
+
Adding a new host: create `integrations/<name>-host/`, put whatever config your host needs, point it at `pilot-workers dispatch`. See `integrations/README.md`.
|
|
100
|
+
|
|
101
|
+
## Architecture
|
|
102
|
+
|
|
103
|
+
See [CLAUDE.md](CLAUDE.md) for the current architecture, module reference, and conventions. See [docs/architecture.md](docs/architecture.md) for the detailed contract and security model.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
109
|
+
.venv/bin/pytest # 130 tests, all offline
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Concurrent multi-job dispatch with aggregated verdicts.
|
|
3
|
+
|
|
4
|
+
Takes N pre-written task files, dispatches them concurrently (each via a
|
|
5
|
+
``pilot_workers.cli.dispatch`` child process), re-emits each child's
|
|
6
|
+
``worker_runner.started`` event on stderr, and prints ONE JSON array of
|
|
7
|
+
verdicts on stdout -- one object per job, in the original job order.
|
|
8
|
+
|
|
9
|
+
Log note: same-provider concurrent jobs interleave that provider's
|
|
10
|
+
``latest.log`` (human log only; per-run jsonl/verdict.json files are still
|
|
11
|
+
separate).
|
|
12
|
+
|
|
13
|
+
Stdout contract (callers depend on it): exactly one JSON array, nothing
|
|
14
|
+
else. Started events and warnings go to stderr only.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from pilot_workers import policy, providers, runtime
|
|
31
|
+
from pilot_workers.runners import get_runner
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
FANOUT_ERROR_EXIT = 2
|
|
35
|
+
DEFAULT_TIMEOUT_S = 3600
|
|
36
|
+
DEFAULT_IDLE_TIMEOUT_S = 900
|
|
37
|
+
JOBS_FILE_FIELDS = {"provider", "mode", "task_file", "worktree"}
|
|
38
|
+
SUCCESS_VERDICTS = ("completed", "step_capped_partial")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Job:
|
|
43
|
+
provider: str
|
|
44
|
+
mode: str
|
|
45
|
+
task_file: str
|
|
46
|
+
worktree: bool = False
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def label(self) -> str:
|
|
50
|
+
return f"{self.provider}:{self.mode}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
description=(
|
|
56
|
+
"Dispatch several jobs concurrently; stdout is one JSON array "
|
|
57
|
+
"of verdicts in the original job order."
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--workdir",
|
|
62
|
+
default=None,
|
|
63
|
+
help="Existing project directory passed to every worker.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--job",
|
|
67
|
+
action="append",
|
|
68
|
+
default=[],
|
|
69
|
+
metavar="PROVIDER:MODE:TASK_FILE",
|
|
70
|
+
help="One job spec; repeatable. Parsed with a left split (paths may "
|
|
71
|
+
"contain colons).",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--jobs-file",
|
|
75
|
+
default=None,
|
|
76
|
+
metavar="JSON",
|
|
77
|
+
help="JSON array of job objects: provider, mode, task_file, worktree.",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--providers",
|
|
81
|
+
default=None,
|
|
82
|
+
metavar="KEYS",
|
|
83
|
+
help="Comma-separated provider keys (shorthand; requires --mode and "
|
|
84
|
+
"--task-file).",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--mode",
|
|
88
|
+
default=None,
|
|
89
|
+
help="Worker mode for the --providers shorthand.",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--task-file",
|
|
93
|
+
default=None,
|
|
94
|
+
help="Shared task file for the --providers shorthand.",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--max-parallel",
|
|
98
|
+
type=int,
|
|
99
|
+
default=None,
|
|
100
|
+
metavar="N",
|
|
101
|
+
help="Maximum concurrent jobs (default: number of jobs).",
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--timeout",
|
|
105
|
+
type=int,
|
|
106
|
+
default=DEFAULT_TIMEOUT_S,
|
|
107
|
+
help=f"Per-job wall-clock limit in seconds (default {DEFAULT_TIMEOUT_S}).",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--idle-timeout",
|
|
111
|
+
type=int,
|
|
112
|
+
default=DEFAULT_IDLE_TIMEOUT_S,
|
|
113
|
+
help=f"Per-job idle limit in seconds (default {DEFAULT_IDLE_TIMEOUT_S}).",
|
|
114
|
+
)
|
|
115
|
+
return parser.parse_args(argv)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Job list construction / validation
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _SpecError(RuntimeError):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _validate_job(job: Job) -> None:
|
|
128
|
+
if job.provider not in providers.PROVIDERS:
|
|
129
|
+
raise _SpecError(f"unknown provider: {job.provider}")
|
|
130
|
+
if job.mode not in policy.MODE_TO_AGENT:
|
|
131
|
+
raise _SpecError(f"unknown mode: {job.mode}")
|
|
132
|
+
if job.mode == "resume":
|
|
133
|
+
raise _SpecError("resume is not supported in fanout")
|
|
134
|
+
if not Path(job.task_file).is_file():
|
|
135
|
+
raise _SpecError(f"task file not found: {job.task_file}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_job_spec(spec: str) -> Job:
|
|
139
|
+
parts = spec.split(":", 2)
|
|
140
|
+
if len(parts) != 3 or not all(parts):
|
|
141
|
+
raise _SpecError(f"invalid --job spec (expected PROVIDER:MODE:TASK_FILE): {spec}")
|
|
142
|
+
return Job(provider=parts[0], mode=parts[1], task_file=parts[2])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _load_jobs_file(path_arg: str) -> list[Job]:
|
|
146
|
+
try:
|
|
147
|
+
with open(path_arg, "r", encoding="utf-8") as handle:
|
|
148
|
+
data = json.load(handle)
|
|
149
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
150
|
+
raise _SpecError(f"cannot read jobs file: {exc}")
|
|
151
|
+
if not isinstance(data, list):
|
|
152
|
+
raise _SpecError("jobs file must be a JSON array of job objects")
|
|
153
|
+
jobs: list[Job] = []
|
|
154
|
+
for entry in data:
|
|
155
|
+
if not isinstance(entry, dict):
|
|
156
|
+
raise _SpecError("jobs file entries must be objects")
|
|
157
|
+
unknown = set(entry) - JOBS_FILE_FIELDS
|
|
158
|
+
if unknown:
|
|
159
|
+
raise _SpecError(
|
|
160
|
+
f"unknown jobs file fields: {', '.join(sorted(unknown))}"
|
|
161
|
+
)
|
|
162
|
+
missing = {"provider", "mode", "task_file"} - set(entry)
|
|
163
|
+
if missing:
|
|
164
|
+
raise _SpecError(
|
|
165
|
+
f"jobs file entry missing fields: {', '.join(sorted(missing))}"
|
|
166
|
+
)
|
|
167
|
+
jobs.append(
|
|
168
|
+
Job(
|
|
169
|
+
provider=str(entry["provider"]),
|
|
170
|
+
mode=str(entry["mode"]),
|
|
171
|
+
task_file=str(entry["task_file"]),
|
|
172
|
+
worktree=bool(entry.get("worktree", False)),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return jobs
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _build_jobs(args: argparse.Namespace) -> list[Job]:
|
|
179
|
+
shorthand_used = any([args.providers, args.mode, args.task_file])
|
|
180
|
+
explicit_used = bool(args.job) or args.jobs_file is not None
|
|
181
|
+
if args.job and args.jobs_file is not None:
|
|
182
|
+
raise _SpecError("--job and --jobs-file are mutually exclusive")
|
|
183
|
+
if shorthand_used and explicit_used:
|
|
184
|
+
raise _SpecError(
|
|
185
|
+
"--providers/--mode/--task-file are mutually exclusive with "
|
|
186
|
+
"--job and --jobs-file"
|
|
187
|
+
)
|
|
188
|
+
if shorthand_used:
|
|
189
|
+
if not (args.providers and args.mode and args.task_file):
|
|
190
|
+
raise _SpecError("--providers requires --mode and --task-file")
|
|
191
|
+
jobs = [
|
|
192
|
+
Job(provider=key.strip(), mode=args.mode, task_file=args.task_file)
|
|
193
|
+
for key in args.providers.split(",")
|
|
194
|
+
if key.strip()
|
|
195
|
+
]
|
|
196
|
+
elif args.jobs_file is not None:
|
|
197
|
+
jobs = _load_jobs_file(args.jobs_file)
|
|
198
|
+
else:
|
|
199
|
+
jobs = [_parse_job_spec(spec) for spec in args.job]
|
|
200
|
+
if not jobs:
|
|
201
|
+
raise _SpecError("at least one job is required")
|
|
202
|
+
for job in jobs:
|
|
203
|
+
_validate_job(job)
|
|
204
|
+
return jobs
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Safety guards
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _credential_preflight(jobs: list[Job]) -> None:
|
|
213
|
+
seen: set[str] = set()
|
|
214
|
+
for job in jobs:
|
|
215
|
+
if job.provider in seen:
|
|
216
|
+
continue
|
|
217
|
+
seen.add(job.provider)
|
|
218
|
+
provider = providers.PROVIDERS[job.provider]
|
|
219
|
+
meta = runtime.credential_metadata(provider, get_runner(provider.runner))
|
|
220
|
+
if not meta.get("configured"):
|
|
221
|
+
raise _SpecError(
|
|
222
|
+
f"credential missing for {job.provider}; "
|
|
223
|
+
f"run: pilot-workers credentials {job.provider}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _workdir_collision_guard(jobs: list[Job]) -> None:
|
|
228
|
+
code_jobs = [job for job in jobs if job.mode == "code"]
|
|
229
|
+
if len(code_jobs) >= 2 and not all(job.worktree for job in code_jobs):
|
|
230
|
+
raise _SpecError(
|
|
231
|
+
"multiple code jobs share one workdir; use --jobs-file with "
|
|
232
|
+
'per-job "worktree": true'
|
|
233
|
+
)
|
|
234
|
+
test_jobs = [job for job in jobs if job.mode == "test"]
|
|
235
|
+
if len(test_jobs) >= 2:
|
|
236
|
+
print(
|
|
237
|
+
"note: concurrent test jobs share a workdir; caches may collide",
|
|
238
|
+
file=sys.stderr,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Execution
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _build_dispatch_command(
|
|
248
|
+
job: Job, workdir: str, timeout: int, idle_timeout: int
|
|
249
|
+
) -> list[str]:
|
|
250
|
+
cmd: list[str] = [
|
|
251
|
+
sys.executable,
|
|
252
|
+
"-m", "pilot_workers.cli.dispatch",
|
|
253
|
+
"--provider",
|
|
254
|
+
job.provider,
|
|
255
|
+
"--mode",
|
|
256
|
+
job.mode,
|
|
257
|
+
"--workdir",
|
|
258
|
+
workdir,
|
|
259
|
+
"--task-file",
|
|
260
|
+
job.task_file,
|
|
261
|
+
]
|
|
262
|
+
if job.worktree:
|
|
263
|
+
cmd.append("--worktree")
|
|
264
|
+
cmd.extend(["--timeout", str(timeout)])
|
|
265
|
+
cmd.extend(["--idle-timeout", str(idle_timeout)])
|
|
266
|
+
return cmd
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _synthesized_verdict(
|
|
270
|
+
job: Job, exit_code: Any, stderr_tail: str, interrupted: bool = False
|
|
271
|
+
) -> dict[str, Any]:
|
|
272
|
+
verdict: dict[str, Any] = {
|
|
273
|
+
"job": job.label,
|
|
274
|
+
"type": "worker_runner.verdict",
|
|
275
|
+
"verdict": "error",
|
|
276
|
+
"synthesized": True,
|
|
277
|
+
"exit_code": exit_code,
|
|
278
|
+
"stderr_tail": stderr_tail,
|
|
279
|
+
}
|
|
280
|
+
if interrupted:
|
|
281
|
+
verdict["interrupted"] = True
|
|
282
|
+
return verdict
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _run_job(
|
|
286
|
+
index: int,
|
|
287
|
+
job: Job,
|
|
288
|
+
cmd: list[str],
|
|
289
|
+
results: list[dict[str, Any] | None],
|
|
290
|
+
procs: list[Any],
|
|
291
|
+
stderr_lock: threading.Lock,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Run one dispatch child; never raises out -- always records a verdict."""
|
|
294
|
+
proc = None
|
|
295
|
+
try:
|
|
296
|
+
proc = subprocess.Popen(
|
|
297
|
+
cmd,
|
|
298
|
+
stdout=subprocess.PIPE,
|
|
299
|
+
stderr=subprocess.PIPE,
|
|
300
|
+
text=True,
|
|
301
|
+
bufsize=1,
|
|
302
|
+
)
|
|
303
|
+
procs[index] = proc
|
|
304
|
+
|
|
305
|
+
stderr_chunks: list[str] = []
|
|
306
|
+
|
|
307
|
+
def _drain_stderr() -> None:
|
|
308
|
+
try:
|
|
309
|
+
assert proc.stderr is not None
|
|
310
|
+
stderr_chunks.append(proc.stderr.read())
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
drain = threading.Thread(target=_drain_stderr, daemon=True)
|
|
315
|
+
drain.start()
|
|
316
|
+
|
|
317
|
+
started_seen = False
|
|
318
|
+
last_line = ""
|
|
319
|
+
assert proc.stdout is not None
|
|
320
|
+
for raw_line in proc.stdout:
|
|
321
|
+
line = raw_line.rstrip("\n")
|
|
322
|
+
if not line:
|
|
323
|
+
continue
|
|
324
|
+
if not started_seen:
|
|
325
|
+
started_seen = True
|
|
326
|
+
try:
|
|
327
|
+
event = json.loads(line)
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
event = None
|
|
330
|
+
if isinstance(event, dict):
|
|
331
|
+
with stderr_lock:
|
|
332
|
+
sys.stderr.write(
|
|
333
|
+
json.dumps({"job": job.label, **event},
|
|
334
|
+
ensure_ascii=False)
|
|
335
|
+
+ "\n"
|
|
336
|
+
)
|
|
337
|
+
sys.stderr.flush()
|
|
338
|
+
last_line = line
|
|
339
|
+
proc.wait()
|
|
340
|
+
drain.join(timeout=5)
|
|
341
|
+
stderr_text = "".join(stderr_chunks)
|
|
342
|
+
|
|
343
|
+
verdict: dict[str, Any] | None = None
|
|
344
|
+
if last_line:
|
|
345
|
+
try:
|
|
346
|
+
parsed = json.loads(last_line)
|
|
347
|
+
except json.JSONDecodeError:
|
|
348
|
+
parsed = None
|
|
349
|
+
if isinstance(parsed, dict):
|
|
350
|
+
parsed["job"] = job.label
|
|
351
|
+
verdict = parsed
|
|
352
|
+
if verdict is None:
|
|
353
|
+
verdict = _synthesized_verdict(
|
|
354
|
+
job, proc.returncode, stderr_text[-500:]
|
|
355
|
+
)
|
|
356
|
+
results[index] = verdict
|
|
357
|
+
except Exception as exc: # the array must always be printed
|
|
358
|
+
results[index] = _synthesized_verdict(
|
|
359
|
+
job, getattr(proc, "returncode", None), str(exc)[-500:]
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def run_fanout(args: argparse.Namespace) -> int:
|
|
364
|
+
if not args.workdir:
|
|
365
|
+
print("error: --workdir is required", file=sys.stderr)
|
|
366
|
+
return FANOUT_ERROR_EXIT
|
|
367
|
+
try:
|
|
368
|
+
jobs = _build_jobs(args)
|
|
369
|
+
_credential_preflight(jobs)
|
|
370
|
+
_workdir_collision_guard(jobs)
|
|
371
|
+
except _SpecError as exc:
|
|
372
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
373
|
+
return FANOUT_ERROR_EXIT
|
|
374
|
+
|
|
375
|
+
max_workers = args.max_parallel or len(jobs)
|
|
376
|
+
results: list[dict[str, Any] | None] = [None] * len(jobs)
|
|
377
|
+
procs: list[Any] = [None] * len(jobs)
|
|
378
|
+
stderr_lock = threading.Lock()
|
|
379
|
+
interrupted = False
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
383
|
+
futures = [
|
|
384
|
+
pool.submit(
|
|
385
|
+
_run_job,
|
|
386
|
+
index,
|
|
387
|
+
job,
|
|
388
|
+
_build_dispatch_command(
|
|
389
|
+
job, args.workdir, args.timeout, args.idle_timeout
|
|
390
|
+
),
|
|
391
|
+
results,
|
|
392
|
+
procs,
|
|
393
|
+
stderr_lock,
|
|
394
|
+
)
|
|
395
|
+
for index, job in enumerate(jobs)
|
|
396
|
+
]
|
|
397
|
+
for future in futures:
|
|
398
|
+
future.result()
|
|
399
|
+
except KeyboardInterrupt:
|
|
400
|
+
interrupted = True
|
|
401
|
+
# Children received SIGINT via the process group and self-terminate;
|
|
402
|
+
# give them a bounded grace period to reap.
|
|
403
|
+
deadline = time.monotonic() + runtime.TERMINATE_GRACE_SECONDS + 5
|
|
404
|
+
for proc in procs:
|
|
405
|
+
if proc is None:
|
|
406
|
+
continue
|
|
407
|
+
remaining = deadline - time.monotonic()
|
|
408
|
+
if remaining <= 0:
|
|
409
|
+
break
|
|
410
|
+
try:
|
|
411
|
+
proc.wait(timeout=remaining)
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
for index, job in enumerate(jobs):
|
|
416
|
+
if results[index] is None:
|
|
417
|
+
results[index] = _synthesized_verdict(job, None, "", interrupted)
|
|
418
|
+
|
|
419
|
+
final: list[dict[str, Any]] = [r for r in results if r is not None]
|
|
420
|
+
sys.stdout.write(json.dumps(final, ensure_ascii=False) + "\n")
|
|
421
|
+
sys.stdout.flush()
|
|
422
|
+
|
|
423
|
+
if interrupted:
|
|
424
|
+
return 1
|
|
425
|
+
if all(r.get("verdict") in SUCCESS_VERDICTS for r in final):
|
|
426
|
+
return 0
|
|
427
|
+
return 1
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def main(argv: list[str] | None = None) -> int:
|
|
431
|
+
args = parse_args(argv)
|
|
432
|
+
return run_fanout(args)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
if __name__ == "__main__":
|
|
436
|
+
raise SystemExit(main())
|