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.
- {pilot_workers-0.3.0/src/pilot_workers.egg-info → pilot_workers-0.4.0}/PKG-INFO +2 -1
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/README.md +1 -0
- {pilot_workers-0.3.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.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/main.py +6 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0/src/pilot_workers.egg-info}/PKG-INFO +2 -1
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/SOURCES.txt +2 -0
- pilot_workers-0.4.0/tests/test_fanout.py +346 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/LICENSE +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/setup.cfg +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/__init__.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/__main__.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/__init__.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/dispatch.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/install.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/run.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/cli/status.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/credentials.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/README.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/relaxed.yaml +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/permissions/strict.yaml +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/README.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/ds.yaml +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/glm.yaml +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/providers/kimi-k3.yaml +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/code.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/explore.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/review.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/data/templates/test.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/fmt_events.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/README.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-coder.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-explorer.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-reviewer.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/ds-tester.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-coder.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-explorer.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-reviewer.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/glm-tester.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-coder.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-explorer.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-reviewer.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/agents/kimi-tester.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/code.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/explore.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/review.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/glm/test.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/code.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/explore.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/review.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/claude-host/commands/kimi/test.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/SKILL.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/openai.yaml +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/glm/SKILL.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/glm/openai.yaml +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/kimi/SKILL.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/kimi/openai.yaml +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/maintain.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/policy.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/code.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/common.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/explore.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/review.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/prompts/test.md +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/providers.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/__init__.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/base.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/runners/opencode_runner.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/runtime.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/scripts/install_runtime.sh +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/dependency_links.txt +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/entry_points.txt +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/requires.txt +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers.egg-info/top_level.txt +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_cli_main.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_credentials.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_dispatch.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_install.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_opencode_runner.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_policy.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_providers.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_render_unified.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_run_cli.py +0 -0
- {pilot_workers-0.3.0 → pilot_workers-0.4.0}/tests/test_runtime.py +0 -0
- {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
|
+
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>
|
|
@@ -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
|
+
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/SKILL.md
RENAMED
|
File without changes
|
{pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/ds/openai.yaml
RENAMED
|
File without changes
|
{pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/glm/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
{pilot_workers-0.3.0 → pilot_workers-0.4.0}/src/pilot_workers/integrations/codex-host/kimi/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|