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.
Files changed (90) hide show
  1. pilot_workers-0.4.0/PKG-INFO +129 -0
  2. pilot_workers-0.4.0/README.md +114 -0
  3. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/pyproject.toml +1 -1
  4. pilot_workers-0.4.0/src/pilot_workers/cli/fanout.py +436 -0
  5. pilot_workers-0.4.0/src/pilot_workers/cli/install.py +520 -0
  6. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/main.py +25 -11
  7. pilot_workers-0.4.0/src/pilot_workers/cli/status.py +178 -0
  8. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/credentials.py +7 -21
  9. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/kimi-k3.yaml +1 -0
  10. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/fmt_events.py +0 -1
  11. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/maintain.py +5 -2
  12. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/policy.py +2 -2
  13. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/providers.py +26 -3
  14. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/__init__.py +1 -1
  15. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/base.py +2 -2
  16. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/opencode_runner.py +22 -37
  17. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/runtime.py +8 -6
  18. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/scripts/install_runtime.sh +1 -2
  19. pilot_workers-0.4.0/src/pilot_workers.egg-info/PKG-INFO +129 -0
  20. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/SOURCES.txt +5 -1
  21. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_cli_main.py +22 -6
  22. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_dispatch.py +12 -0
  23. pilot_workers-0.4.0/tests/test_fanout.py +346 -0
  24. pilot_workers-0.4.0/tests/test_install.py +366 -0
  25. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_opencode_runner.py +1 -1
  26. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_providers.py +16 -0
  27. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_render_unified.py +27 -1
  28. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_runtime.py +22 -2
  29. pilot_workers-0.4.0/tests/test_status.py +123 -0
  30. pilot_workers-0.2.0/PKG-INFO +0 -84
  31. pilot_workers-0.2.0/README.md +0 -69
  32. pilot_workers-0.2.0/src/pilot_workers/cli/install.py +0 -259
  33. pilot_workers-0.2.0/src/pilot_workers.egg-info/PKG-INFO +0 -84
  34. pilot_workers-0.2.0/tests/test_install.py +0 -126
  35. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/LICENSE +0 -0
  36. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/setup.cfg +0 -0
  37. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/__init__.py +0 -0
  38. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/__main__.py +0 -0
  39. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/__init__.py +0 -0
  40. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/dispatch.py +0 -0
  41. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/run.py +0 -0
  42. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/README.md +0 -0
  43. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/relaxed.yaml +0 -0
  44. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/strict.yaml +0 -0
  45. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/README.md +0 -0
  46. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/ds.yaml +0 -0
  47. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/glm.yaml +0 -0
  48. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/code.md +0 -0
  49. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/explore.md +0 -0
  50. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/review.md +0 -0
  51. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/test.md +0 -0
  52. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/README.md +0 -0
  53. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-coder.md +0 -0
  54. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-explorer.md +0 -0
  55. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-reviewer.md +0 -0
  56. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-tester.md +0 -0
  57. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-coder.md +0 -0
  58. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-explorer.md +0 -0
  59. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-reviewer.md +0 -0
  60. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-tester.md +0 -0
  61. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-coder.md +0 -0
  62. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-explorer.md +0 -0
  63. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-reviewer.md +0 -0
  64. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-tester.md +0 -0
  65. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/code.md +0 -0
  66. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/explore.md +0 -0
  67. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/review.md +0 -0
  68. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/test.md +0 -0
  69. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/code.md +0 -0
  70. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/explore.md +0 -0
  71. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/review.md +0 -0
  72. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/test.md +0 -0
  73. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/SKILL.md +0 -0
  74. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/openai.yaml +0 -0
  75. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/glm/SKILL.md +0 -0
  76. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/glm/openai.yaml +0 -0
  77. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/kimi/SKILL.md +0 -0
  78. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/kimi/openai.yaml +0 -0
  79. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/code.md +0 -0
  80. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/common.md +0 -0
  81. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/explore.md +0 -0
  82. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/review.md +0 -0
  83. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/test.md +0 -0
  84. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/dependency_links.txt +0 -0
  85. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/entry_points.txt +0 -0
  86. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/requires.txt +0 -0
  87. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/top_level.txt +0 -0
  88. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_credentials.py +0 -0
  89. {pilot_workers-0.2.0 → pilot_workers-0.4.0}/tests/test_policy.py +0 -0
  90. {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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pilot-workers"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Dispatch bounded tasks to isolated LLM workers (GLM, Kimi, and more)"
9
9
  readme = "README.md"
10
10
  license = "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())