pilot-workers 0.3.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 (85) hide show
  1. {pilot_workers-0.3.0/src/pilot_workers.egg-info → pilot_workers-0.4.0}/PKG-INFO +2 -1
  2. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/README.md +1 -0
  3. {pilot_workers-0.3.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.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/main.py +6 -0
  6. {pilot_workers-0.3.0 → pilot_workers-0.4.0/src/pilot_workers.egg-info}/PKG-INFO +2 -1
  7. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/SOURCES.txt +2 -0
  8. pilot_workers-0.4.0/tests/test_fanout.py +346 -0
  9. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/LICENSE +0 -0
  10. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/setup.cfg +0 -0
  11. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/__init__.py +0 -0
  12. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/__main__.py +0 -0
  13. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/__init__.py +0 -0
  14. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/dispatch.py +0 -0
  15. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/install.py +0 -0
  16. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/run.py +0 -0
  17. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/status.py +0 -0
  18. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/credentials.py +0 -0
  19. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/README.md +0 -0
  20. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/relaxed.yaml +0 -0
  21. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/strict.yaml +0 -0
  22. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/README.md +0 -0
  23. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/ds.yaml +0 -0
  24. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/glm.yaml +0 -0
  25. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/kimi-k3.yaml +0 -0
  26. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/code.md +0 -0
  27. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/explore.md +0 -0
  28. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/review.md +0 -0
  29. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/test.md +0 -0
  30. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/fmt_events.py +0 -0
  31. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/README.md +0 -0
  32. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-coder.md +0 -0
  33. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-explorer.md +0 -0
  34. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-reviewer.md +0 -0
  35. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-tester.md +0 -0
  36. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-coder.md +0 -0
  37. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-explorer.md +0 -0
  38. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-reviewer.md +0 -0
  39. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-tester.md +0 -0
  40. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-coder.md +0 -0
  41. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-explorer.md +0 -0
  42. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-reviewer.md +0 -0
  43. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-tester.md +0 -0
  44. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/code.md +0 -0
  45. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/explore.md +0 -0
  46. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/review.md +0 -0
  47. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/test.md +0 -0
  48. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/code.md +0 -0
  49. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/explore.md +0 -0
  50. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/review.md +0 -0
  51. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/test.md +0 -0
  52. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/SKILL.md +0 -0
  53. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/openai.yaml +0 -0
  54. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/glm/SKILL.md +0 -0
  55. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/glm/openai.yaml +0 -0
  56. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/kimi/SKILL.md +0 -0
  57. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/kimi/openai.yaml +0 -0
  58. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/maintain.py +0 -0
  59. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/policy.py +0 -0
  60. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/code.md +0 -0
  61. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/common.md +0 -0
  62. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/explore.md +0 -0
  63. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/review.md +0 -0
  64. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/test.md +0 -0
  65. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/providers.py +0 -0
  66. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/__init__.py +0 -0
  67. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/base.py +0 -0
  68. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/opencode_runner.py +0 -0
  69. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/runtime.py +0 -0
  70. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/scripts/install_runtime.sh +0 -0
  71. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/dependency_links.txt +0 -0
  72. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/entry_points.txt +0 -0
  73. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/requires.txt +0 -0
  74. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/top_level.txt +0 -0
  75. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_cli_main.py +0 -0
  76. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_credentials.py +0 -0
  77. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_dispatch.py +0 -0
  78. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_install.py +0 -0
  79. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_opencode_runner.py +0 -0
  80. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_policy.py +0 -0
  81. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_providers.py +0 -0
  82. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_render_unified.py +0 -0
  83. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_run_cli.py +0 -0
  84. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_runtime.py +0 -0
  85. {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_status.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pilot-workers
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Dispatch bounded tasks to isolated LLM workers (GLM, Kimi, and more)
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -69,6 +69,7 @@ pilot-workers <subcommand> [args]
69
69
 
70
70
  run Dispatch a task (streaming output).
71
71
  dispatch Deterministic wrapper around run (two-line JSON: started + verdict).
72
+ fanout Dispatch several jobs concurrently; stdout = one JSON array of verdicts.
72
73
  template Print the task template for a mode (code|explore|test|review).
73
74
  install Install integrations or runner.
74
75
  install <provider|all> on <host|all>
@@ -54,6 +54,7 @@ pilot-workers <subcommand> [args]
54
54
 
55
55
  run Dispatch a task (streaming output).
56
56
  dispatch Deterministic wrapper around run (two-line JSON: started + verdict).
57
+ fanout Dispatch several jobs concurrently; stdout = one JSON array of verdicts.
57
58
  template Print the task template for a mode (code|explore|test|review).
58
59
  install Install integrations or runner.
59
60
  install <provider|all> on <host|all>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pilot-workers"
7
- version = "0.3.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())
@@ -12,6 +12,7 @@ USAGE = """usage: pilot-workers <subcommand> [args]
12
12
  subcommands:
13
13
  run Dispatch a bounded task to an isolated LLM worker.
14
14
  dispatch Deterministic outer shell around run (started + verdict JSON).
15
+ fanout Dispatch several jobs concurrently; stdout = one JSON array of verdicts.
15
16
  template Print the task template for a mode (code|explore|test|review).
16
17
  install install <provider|all> on <host|all> | install runner <name>.
17
18
  uninstall uninstall <provider|all> on <host|all> | uninstall runner <name>.
@@ -55,6 +56,11 @@ def main(argv: list[str] | None = None) -> int:
55
56
 
56
57
  return dispatch_main(rest)
57
58
 
59
+ if subcommand == "fanout":
60
+ from pilot_workers.cli.fanout import main as fanout_main
61
+
62
+ return fanout_main(rest)
63
+
58
64
  if subcommand == "template":
59
65
  import pilot_workers
60
66
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pilot-workers
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Dispatch bounded tasks to isolated LLM workers (GLM, Kimi, and more)
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -69,6 +69,7 @@ pilot-workers <subcommand> [args]
69
69
 
70
70
  run Dispatch a task (streaming output).
71
71
  dispatch Deterministic wrapper around run (two-line JSON: started + verdict).
72
+ fanout Dispatch several jobs concurrently; stdout = one JSON array of verdicts.
72
73
  template Print the task template for a mode (code|explore|test|review).
73
74
  install Install integrations or runner.
74
75
  install <provider|all> on <host|all>
@@ -17,6 +17,7 @@ src/pilot_workers.egg-info/requires.txt
17
17
  src/pilot_workers.egg-info/top_level.txt
18
18
  src/pilot_workers/cli/__init__.py
19
19
  src/pilot_workers/cli/dispatch.py
20
+ src/pilot_workers/cli/fanout.py
20
21
  src/pilot_workers/cli/install.py
21
22
  src/pilot_workers/cli/main.py
22
23
  src/pilot_workers/cli/run.py
@@ -71,6 +72,7 @@ src/pilot_workers/scripts/install_runtime.sh
71
72
  tests/test_cli_main.py
72
73
  tests/test_credentials.py
73
74
  tests/test_dispatch.py
75
+ tests/test_fanout.py
74
76
  tests/test_install.py
75
77
  tests/test_opencode_runner.py
76
78
  tests/test_policy.py
@@ -0,0 +1,346 @@
1
+ """Offline tests for pilot_workers.cli.fanout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+
8
+ import pytest
9
+
10
+ from pilot_workers import runtime
11
+ from pilot_workers.cli import fanout as fanout_mod
12
+
13
+
14
+ def _allow_credentials(monkeypatch):
15
+ monkeypatch.setattr(
16
+ runtime,
17
+ "credential_metadata",
18
+ lambda provider, runner: {"configured": True, "secure_mode": True},
19
+ )
20
+
21
+
22
+ def _deny_credentials(monkeypatch):
23
+ monkeypatch.setattr(
24
+ runtime,
25
+ "credential_metadata",
26
+ lambda provider, runner: {"configured": False, "secure_mode": False},
27
+ )
28
+
29
+
30
+ class _FakePopen:
31
+ """Minimal Popen stand-in: canned stdout lines, stderr text, returncode."""
32
+
33
+ def __init__(self, cmd, behavior):
34
+ self.cmd = cmd
35
+ lines = behavior.get("stdout_lines", [])
36
+ self.stdout = io.StringIO("".join(line + "\n" for line in lines))
37
+ self.stderr = io.StringIO(behavior.get("stderr", ""))
38
+ self._rc = behavior.get("returncode", 0)
39
+ self.returncode = None
40
+
41
+ def wait(self, timeout=None):
42
+ self.returncode = self._rc
43
+ return self._rc
44
+
45
+ def poll(self):
46
+ return self.returncode
47
+
48
+
49
+ def _install_fake_popen(monkeypatch, behavior_for):
50
+ """Patch subprocess.Popen; behavior_for(cmd) -> behavior dict.
51
+
52
+ Returns the list of commands seen (thread-safe enough for tests).
53
+ """
54
+ calls = []
55
+
56
+ def fake_popen(cmd, stdout=None, stderr=None, text=None, bufsize=None):
57
+ calls.append(cmd)
58
+ return _FakePopen(cmd, behavior_for(cmd))
59
+
60
+ monkeypatch.setattr(fanout_mod.subprocess, "Popen", fake_popen)
61
+ return calls
62
+
63
+
64
+ def _provider_of(cmd):
65
+ return cmd[cmd.index("--provider") + 1]
66
+
67
+
68
+ def _verdict_line(verdict, provider="glm"):
69
+ return json.dumps({
70
+ "type": "worker_runner.verdict",
71
+ "provider": provider,
72
+ "verdict": verdict,
73
+ "exit_code": 0,
74
+ })
75
+
76
+
77
+ def _started_line(provider="glm"):
78
+ return json.dumps({
79
+ "type": "worker_runner.started",
80
+ "provider": provider,
81
+ "run_id": "r1",
82
+ })
83
+
84
+
85
+ def _ok_behavior(cmd):
86
+ provider = _provider_of(cmd)
87
+ return {
88
+ "stdout_lines": [_started_line(provider), _verdict_line("completed", provider)],
89
+ "returncode": 0,
90
+ }
91
+
92
+
93
+ @pytest.fixture
94
+ def task_file(tmp_path):
95
+ path = tmp_path / "task.md"
96
+ path.write_text("# task\n", encoding="utf-8")
97
+ return str(path)
98
+
99
+
100
+ @pytest.fixture
101
+ def workdir(tmp_path):
102
+ return str(tmp_path)
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Grammar
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ def test_job_spec_parses(monkeypatch, capsys, tmp_path, task_file, workdir):
111
+ _allow_credentials(monkeypatch)
112
+ _install_fake_popen(monkeypatch, _ok_behavior)
113
+ rc = fanout_mod.main([
114
+ "--workdir", workdir, "--job", f"glm:review:{task_file}",
115
+ ])
116
+ assert rc == 0
117
+ results = json.loads(capsys.readouterr().out)
118
+ assert [r["job"] for r in results] == ["glm:review"]
119
+
120
+
121
+ def test_job_spec_path_with_colons_parses_via_left_split(
122
+ monkeypatch, capsys, tmp_path, workdir
123
+ ):
124
+ colon_path = tmp_path / "a:b.md"
125
+ colon_path.write_text("# task\n", encoding="utf-8")
126
+ _allow_credentials(monkeypatch)
127
+ calls = _install_fake_popen(monkeypatch, _ok_behavior)
128
+ rc = fanout_mod.main([
129
+ "--workdir", workdir, "--job", f"glm:review:{colon_path}",
130
+ ])
131
+ assert rc == 0
132
+ assert calls[0][calls[0].index("--task-file") + 1] == str(colon_path)
133
+
134
+
135
+ def test_unknown_provider_exits_2(capsys, task_file, workdir):
136
+ rc = fanout_mod.main([
137
+ "--workdir", workdir, "--job", f"bogus:review:{task_file}",
138
+ ])
139
+ assert rc == 2
140
+ assert "unknown provider" in capsys.readouterr().err
141
+
142
+
143
+ def test_resume_mode_rejected(capsys, task_file, workdir):
144
+ rc = fanout_mod.main([
145
+ "--workdir", workdir, "--job", f"glm:resume:{task_file}",
146
+ ])
147
+ assert rc == 2
148
+ assert "resume is not supported in fanout" in capsys.readouterr().err
149
+
150
+
151
+ def test_no_jobs_exits_2(capsys, workdir):
152
+ rc = fanout_mod.main(["--workdir", workdir])
153
+ assert rc == 2
154
+
155
+
156
+ def test_providers_shorthand_expands(monkeypatch, capsys, task_file, workdir):
157
+ _allow_credentials(monkeypatch)
158
+ calls = _install_fake_popen(monkeypatch, _ok_behavior)
159
+ rc = fanout_mod.main([
160
+ "--workdir", workdir,
161
+ "--providers", "kimi-k3,glm",
162
+ "--mode", "review",
163
+ "--task-file", task_file,
164
+ ])
165
+ assert rc == 0
166
+ results = json.loads(capsys.readouterr().out)
167
+ assert [r["job"] for r in results] == ["kimi-k3:review", "glm:review"]
168
+ assert len(calls) == 2
169
+
170
+
171
+ def test_job_and_jobs_file_together_exits_2(capsys, tmp_path, task_file, workdir):
172
+ jobs_file = tmp_path / "jobs.json"
173
+ jobs_file.write_text("[]", encoding="utf-8")
174
+ rc = fanout_mod.main([
175
+ "--workdir", workdir,
176
+ "--job", f"glm:review:{task_file}",
177
+ "--jobs-file", str(jobs_file),
178
+ ])
179
+ assert rc == 2
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Safety guards
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ def test_credential_preflight_failure_spawns_nothing(
188
+ monkeypatch, capsys, task_file, workdir
189
+ ):
190
+ _deny_credentials(monkeypatch)
191
+
192
+ def forbidden_popen(*args, **kwargs):
193
+ raise AssertionError("Popen must not be called")
194
+
195
+ monkeypatch.setattr(fanout_mod.subprocess, "Popen", forbidden_popen)
196
+ rc = fanout_mod.main([
197
+ "--workdir", workdir, "--job", f"glm:review:{task_file}",
198
+ ])
199
+ assert rc == 2
200
+ err = capsys.readouterr().err
201
+ assert "credential missing for glm" in err
202
+ assert "pilot-workers credentials glm" in err
203
+
204
+
205
+ def _write_jobs_file(tmp_path, jobs):
206
+ path = tmp_path / "jobs.json"
207
+ path.write_text(json.dumps(jobs), encoding="utf-8")
208
+ return str(path)
209
+
210
+
211
+ def test_code_jobs_same_workdir_without_worktree_exits_2(
212
+ monkeypatch, capsys, tmp_path, task_file, workdir
213
+ ):
214
+ _allow_credentials(monkeypatch)
215
+ _install_fake_popen(monkeypatch, _ok_behavior)
216
+ jobs_file = _write_jobs_file(tmp_path, [
217
+ {"provider": "glm", "mode": "code", "task_file": task_file},
218
+ {"provider": "kimi-k3", "mode": "code", "task_file": task_file},
219
+ ])
220
+ rc = fanout_mod.main(["--workdir", workdir, "--jobs-file", jobs_file])
221
+ assert rc == 2
222
+ assert "multiple code jobs share one workdir" in capsys.readouterr().err
223
+
224
+
225
+ def test_code_jobs_with_worktree_proceed(
226
+ monkeypatch, capsys, tmp_path, task_file, workdir
227
+ ):
228
+ _allow_credentials(monkeypatch)
229
+ calls = _install_fake_popen(monkeypatch, _ok_behavior)
230
+ jobs_file = _write_jobs_file(tmp_path, [
231
+ {"provider": "glm", "mode": "code", "task_file": task_file,
232
+ "worktree": True},
233
+ {"provider": "kimi-k3", "mode": "code", "task_file": task_file,
234
+ "worktree": True},
235
+ ])
236
+ rc = fanout_mod.main(["--workdir", workdir, "--jobs-file", jobs_file])
237
+ assert rc == 0
238
+ assert all("--worktree" in cmd for cmd in calls)
239
+
240
+
241
+ def test_test_jobs_same_workdir_warn_but_proceed(
242
+ monkeypatch, capsys, tmp_path, task_file, workdir
243
+ ):
244
+ _allow_credentials(monkeypatch)
245
+ _install_fake_popen(monkeypatch, _ok_behavior)
246
+ jobs_file = _write_jobs_file(tmp_path, [
247
+ {"provider": "glm", "mode": "test", "task_file": task_file},
248
+ {"provider": "kimi-k3", "mode": "test", "task_file": task_file},
249
+ ])
250
+ rc = fanout_mod.main(["--workdir", workdir, "--jobs-file", jobs_file])
251
+ assert rc == 0
252
+ assert "concurrent test jobs share a workdir" in capsys.readouterr().err
253
+
254
+
255
+ def test_jobs_file_unknown_field_exits_2(capsys, tmp_path, task_file, workdir):
256
+ jobs_file = _write_jobs_file(tmp_path, [
257
+ {"provider": "glm", "mode": "review", "task_file": task_file,
258
+ "bogus": 1},
259
+ ])
260
+ rc = fanout_mod.main(["--workdir", workdir, "--jobs-file", jobs_file])
261
+ assert rc == 2
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Aggregation / verdicts
266
+ # ---------------------------------------------------------------------------
267
+
268
+
269
+ def test_aggregation_order_and_started_reemission(
270
+ monkeypatch, capsys, task_file, workdir
271
+ ):
272
+ _allow_credentials(monkeypatch)
273
+ _install_fake_popen(monkeypatch, _ok_behavior)
274
+ rc = fanout_mod.main([
275
+ "--workdir", workdir,
276
+ "--job", f"glm:review:{task_file}",
277
+ "--job", f"kimi-k3:explore:{task_file}",
278
+ ])
279
+ assert rc == 0
280
+ captured = capsys.readouterr()
281
+ results = json.loads(captured.out)
282
+ assert len(results) == 2
283
+ assert [r["job"] for r in results] == ["glm:review", "kimi-k3:explore"]
284
+ assert all(r["type"] == "worker_runner.verdict" for r in results)
285
+ started = [
286
+ json.loads(line) for line in captured.err.splitlines()
287
+ if line.startswith("{")
288
+ ]
289
+ assert {s["job"] for s in started} == {"glm:review", "kimi-k3:explore"}
290
+ assert all(s["type"] == "worker_runner.started" for s in started)
291
+
292
+
293
+ def test_silent_child_gets_synthesized_verdict(
294
+ monkeypatch, capsys, task_file, workdir
295
+ ):
296
+ _allow_credentials(monkeypatch)
297
+
298
+ def behavior_for(cmd):
299
+ if _provider_of(cmd) == "glm":
300
+ return _ok_behavior(cmd)
301
+ return {"stdout_lines": [], "stderr": "boom " * 200, "returncode": 2}
302
+
303
+ _install_fake_popen(monkeypatch, behavior_for)
304
+ rc = fanout_mod.main([
305
+ "--workdir", workdir,
306
+ "--job", f"glm:review:{task_file}",
307
+ "--job", f"kimi-k3:review:{task_file}",
308
+ ])
309
+ assert rc == 1
310
+ results = json.loads(capsys.readouterr().out)
311
+ assert results[0]["job"] == "glm:review"
312
+ assert results[0]["verdict"] == "completed"
313
+ assert "synthesized" not in results[0]
314
+ assert results[1]["job"] == "kimi-k3:review"
315
+ assert results[1]["verdict"] == "error"
316
+ assert results[1]["synthesized"] is True
317
+ assert results[1]["exit_code"] == 2
318
+ assert len(results[1]["stderr_tail"]) <= 500
319
+ assert "boom" in results[1]["stderr_tail"]
320
+
321
+
322
+ @pytest.mark.parametrize("verdict,expected_rc", [
323
+ ("completed", 0),
324
+ ("step_capped_partial", 0),
325
+ ("error", 1),
326
+ ("empty", 1),
327
+ ])
328
+ def test_exit_code_from_verdicts(
329
+ monkeypatch, capsys, task_file, workdir, verdict, expected_rc
330
+ ):
331
+ _allow_credentials(monkeypatch)
332
+
333
+ def behavior_for(cmd):
334
+ provider = _provider_of(cmd)
335
+ return {
336
+ "stdout_lines": [
337
+ _started_line(provider), _verdict_line(verdict, provider),
338
+ ],
339
+ "returncode": 0,
340
+ }
341
+
342
+ _install_fake_popen(monkeypatch, behavior_for)
343
+ rc = fanout_mod.main([
344
+ "--workdir", workdir, "--job", f"glm:review:{task_file}",
345
+ ])
346
+ assert rc == expected_rc
File without changes
File without changes