pilot-workers 0.2.0__py3-none-any.whl
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/__init__.py +1 -0
- pilot_workers/__main__.py +3 -0
- pilot_workers/cli/__init__.py +0 -0
- pilot_workers/cli/dispatch.py +577 -0
- pilot_workers/cli/install.py +259 -0
- pilot_workers/cli/main.py +115 -0
- pilot_workers/cli/run.py +191 -0
- pilot_workers/credentials.py +102 -0
- pilot_workers/data/permissions/README.md +47 -0
- pilot_workers/data/permissions/relaxed.yaml +10 -0
- pilot_workers/data/permissions/strict.yaml +9 -0
- pilot_workers/data/providers/README.md +24 -0
- pilot_workers/data/providers/ds.yaml +11 -0
- pilot_workers/data/providers/glm.yaml +11 -0
- pilot_workers/data/providers/kimi-k3.yaml +11 -0
- pilot_workers/data/templates/code.md +28 -0
- pilot_workers/data/templates/explore.md +17 -0
- pilot_workers/data/templates/review.md +18 -0
- pilot_workers/data/templates/test.md +17 -0
- pilot_workers/fmt_events.py +218 -0
- pilot_workers/integrations/README.md +25 -0
- pilot_workers/integrations/claude-host/agents/ds-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/ds-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/ds-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/ds-tester.md +46 -0
- pilot_workers/integrations/claude-host/agents/glm-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/glm-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/glm-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/glm-tester.md +46 -0
- pilot_workers/integrations/claude-host/agents/kimi-coder.md +66 -0
- pilot_workers/integrations/claude-host/agents/kimi-explorer.md +56 -0
- pilot_workers/integrations/claude-host/agents/kimi-reviewer.md +50 -0
- pilot_workers/integrations/claude-host/agents/kimi-tester.md +50 -0
- pilot_workers/integrations/claude-host/commands/glm/code.md +48 -0
- pilot_workers/integrations/claude-host/commands/glm/explore.md +38 -0
- pilot_workers/integrations/claude-host/commands/glm/review.md +38 -0
- pilot_workers/integrations/claude-host/commands/glm/test.md +30 -0
- pilot_workers/integrations/claude-host/commands/kimi/code.md +47 -0
- pilot_workers/integrations/claude-host/commands/kimi/explore.md +38 -0
- pilot_workers/integrations/claude-host/commands/kimi/review.md +38 -0
- pilot_workers/integrations/claude-host/commands/kimi/test.md +32 -0
- pilot_workers/integrations/codex-host/ds/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/ds/openai.yaml +4 -0
- pilot_workers/integrations/codex-host/glm/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/glm/openai.yaml +4 -0
- pilot_workers/integrations/codex-host/kimi/SKILL.md +15 -0
- pilot_workers/integrations/codex-host/kimi/openai.yaml +4 -0
- pilot_workers/maintain.py +170 -0
- pilot_workers/policy.py +295 -0
- pilot_workers/prompts/code.md +6 -0
- pilot_workers/prompts/common.md +24 -0
- pilot_workers/prompts/explore.md +7 -0
- pilot_workers/prompts/review.md +5 -0
- pilot_workers/prompts/test.md +6 -0
- pilot_workers/providers.py +137 -0
- pilot_workers/runners/__init__.py +28 -0
- pilot_workers/runners/base.py +123 -0
- pilot_workers/runners/opencode_runner.py +377 -0
- pilot_workers/runtime.py +314 -0
- pilot_workers/scripts/install_runtime.sh +51 -0
- pilot_workers-0.2.0.dist-info/METADATA +84 -0
- pilot_workers-0.2.0.dist-info/RECORD +66 -0
- pilot_workers-0.2.0.dist-info/WHEEL +5 -0
- pilot_workers-0.2.0.dist-info/entry_points.txt +2 -0
- pilot_workers-0.2.0.dist-info/licenses/LICENSE +21 -0
- pilot_workers-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pilot-workers: dispatch bounded tasks to isolated LLM workers."""
|
|
File without changes
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic outer shell around cli/run.py.
|
|
3
|
+
|
|
4
|
+
Wraps cli/run.py: launches it as a subprocess, forwards exactly one line
|
|
5
|
+
(the `worker_runner.started` event) to its own stdout, swallows the rest of
|
|
6
|
+
the child's stdout (it is preserved in the JSONL event log on disk), waits
|
|
7
|
+
for the child to exit, parses the JSONL, and prints a final
|
|
8
|
+
`worker_runner.verdict` line.
|
|
9
|
+
|
|
10
|
+
Stdout contract (callers depend on it): exactly two JSON lines, in order --
|
|
11
|
+
the forwarded `started` event and the final `verdict`. Nothing else is ever
|
|
12
|
+
written to stdout by this script.
|
|
13
|
+
|
|
14
|
+
Also supports a reparse mode (`--reparse <jsonl> --mode <mode>`) that skips
|
|
15
|
+
the dispatch step and recomputes a verdict for an existing JSONL event log;
|
|
16
|
+
this is used to post-mortem / re-harvest historical runs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from pilot_workers import policy
|
|
31
|
+
from pilot_workers.runners import RUNNERS, Runner, get_runner
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
DEFAULT_TIMEOUT_S = 3600
|
|
35
|
+
DEFAULT_IDLE_TIMEOUT_S = 900
|
|
36
|
+
DISPATCH_ERROR_EXIT = 2
|
|
37
|
+
VERDICT_SCHEMA_VERSION = 1
|
|
38
|
+
EMPTY_FINAL_TEXT_THRESHOLD = 200
|
|
39
|
+
|
|
40
|
+
DISPATCH_ARG_NAMES = (
|
|
41
|
+
"provider",
|
|
42
|
+
"workdir",
|
|
43
|
+
"task",
|
|
44
|
+
"task_file",
|
|
45
|
+
"session",
|
|
46
|
+
"worktree",
|
|
47
|
+
"timeout",
|
|
48
|
+
"idle_timeout",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
53
|
+
parser = argparse.ArgumentParser(
|
|
54
|
+
description=(
|
|
55
|
+
"Deterministic outer shell around cli/run.py: dispatches a worker "
|
|
56
|
+
"and prints started + verdict JSON, or reparses an existing run."
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--reparse",
|
|
61
|
+
metavar="JSONL",
|
|
62
|
+
default=None,
|
|
63
|
+
help=(
|
|
64
|
+
"Skip dispatch and recompute a verdict for an existing JSONL event "
|
|
65
|
+
"log. Must be combined with --mode and no dispatch arguments."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--mode",
|
|
70
|
+
choices=sorted(policy.MODE_TO_AGENT),
|
|
71
|
+
default=None,
|
|
72
|
+
help="Worker mode; selects the step cap and agent.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--provider",
|
|
76
|
+
default=argparse.SUPPRESS,
|
|
77
|
+
help="Provider key (e.g. glm or kimi-k3).",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--workdir",
|
|
81
|
+
default=argparse.SUPPRESS,
|
|
82
|
+
help="Existing project directory passed to the worker.",
|
|
83
|
+
)
|
|
84
|
+
task_group = parser.add_mutually_exclusive_group()
|
|
85
|
+
task_group.add_argument(
|
|
86
|
+
"--task",
|
|
87
|
+
default=argparse.SUPPRESS,
|
|
88
|
+
help="Short task contract passed inline.",
|
|
89
|
+
)
|
|
90
|
+
task_group.add_argument(
|
|
91
|
+
"--task-file",
|
|
92
|
+
default=argparse.SUPPRESS,
|
|
93
|
+
help="UTF-8 file containing the task contract.",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--session",
|
|
97
|
+
default=argparse.SUPPRESS,
|
|
98
|
+
help="OpenCode session ID (resume mode only).",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--worktree",
|
|
102
|
+
action="store_true",
|
|
103
|
+
default=argparse.SUPPRESS,
|
|
104
|
+
help="Create a detached clean worktree from committed HEAD.",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--timeout",
|
|
108
|
+
type=int,
|
|
109
|
+
default=argparse.SUPPRESS,
|
|
110
|
+
help=f"Wall-clock limit in seconds (default {DEFAULT_TIMEOUT_S}; 0 disables).",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--idle-timeout",
|
|
114
|
+
type=int,
|
|
115
|
+
default=argparse.SUPPRESS,
|
|
116
|
+
help=(
|
|
117
|
+
"Kill the worker after this many seconds without output "
|
|
118
|
+
f"(default {DEFAULT_IDLE_TIMEOUT_S}; 0 disables)."
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--runner",
|
|
123
|
+
choices=sorted(RUNNERS),
|
|
124
|
+
default="opencode",
|
|
125
|
+
help="Runner adapter name (reparse mode only). Default: opencode.",
|
|
126
|
+
)
|
|
127
|
+
return parser.parse_args(argv)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# JSONL event-log parsing
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_jsonl(path: Path, runner: Runner) -> dict[str, Any]:
|
|
136
|
+
"""Extract verdict inputs from a runner JSONL event log.
|
|
137
|
+
|
|
138
|
+
``runner.parse_events`` translates each raw line into 0..n UnifiedEvents;
|
|
139
|
+
the aggregation below is runner-agnostic. Lines that fail json.loads or
|
|
140
|
+
parse_events translation are silently skipped.
|
|
141
|
+
"""
|
|
142
|
+
steps = 0
|
|
143
|
+
tokens = {
|
|
144
|
+
"input": 0,
|
|
145
|
+
"output": 0,
|
|
146
|
+
"reasoning": 0,
|
|
147
|
+
"cache_read": 0,
|
|
148
|
+
"cache_write": 0,
|
|
149
|
+
}
|
|
150
|
+
tool_errors = {"permission_denied": 0, "other": 0}
|
|
151
|
+
final_text = ""
|
|
152
|
+
has_error_event = False
|
|
153
|
+
first_ts: int | None = None
|
|
154
|
+
last_ts: int | None = None
|
|
155
|
+
session_id: str | None = None
|
|
156
|
+
|
|
157
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
158
|
+
for raw_line in handle:
|
|
159
|
+
raw_line = raw_line.strip()
|
|
160
|
+
if not raw_line:
|
|
161
|
+
continue
|
|
162
|
+
try:
|
|
163
|
+
event = json.loads(raw_line)
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
continue
|
|
166
|
+
if not isinstance(event, dict):
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
unified = runner.parse_events(event)
|
|
171
|
+
except Exception:
|
|
172
|
+
continue
|
|
173
|
+
for ev in unified:
|
|
174
|
+
if ev.ts is not None:
|
|
175
|
+
if first_ts is None:
|
|
176
|
+
first_ts = ev.ts
|
|
177
|
+
last_ts = ev.ts
|
|
178
|
+
if ev.kind == "step":
|
|
179
|
+
steps += 1
|
|
180
|
+
if ev.tokens is not None:
|
|
181
|
+
tokens["input"] += ev.tokens.input
|
|
182
|
+
tokens["output"] += ev.tokens.output
|
|
183
|
+
tokens["reasoning"] += ev.tokens.reasoning
|
|
184
|
+
tokens["cache_read"] += ev.tokens.cache_read
|
|
185
|
+
tokens["cache_write"] += ev.tokens.cache_write
|
|
186
|
+
elif ev.kind == "text":
|
|
187
|
+
if ev.text:
|
|
188
|
+
final_text = ev.text
|
|
189
|
+
elif ev.kind == "tool":
|
|
190
|
+
if ev.tool is not None and ev.tool.status == "error":
|
|
191
|
+
if ev.tool.is_permission_denied:
|
|
192
|
+
tool_errors["permission_denied"] += 1
|
|
193
|
+
else:
|
|
194
|
+
tool_errors["other"] += 1
|
|
195
|
+
elif ev.kind == "session":
|
|
196
|
+
if ev.session_id:
|
|
197
|
+
session_id = ev.session_id
|
|
198
|
+
elif ev.kind == "error":
|
|
199
|
+
has_error_event = True
|
|
200
|
+
|
|
201
|
+
if first_ts is not None and last_ts is not None:
|
|
202
|
+
duration_s: int | None = (last_ts - first_ts) // 1000
|
|
203
|
+
else:
|
|
204
|
+
duration_s = None
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
"steps": steps,
|
|
208
|
+
"tokens": tokens,
|
|
209
|
+
"tool_errors": tool_errors,
|
|
210
|
+
"final_text": final_text,
|
|
211
|
+
"has_error_event": has_error_event,
|
|
212
|
+
"duration_s": duration_s,
|
|
213
|
+
"session_id": session_id,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Verdict
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def classify_verdict(
|
|
223
|
+
parsed: dict[str, Any],
|
|
224
|
+
step_cap: int,
|
|
225
|
+
summary: dict[str, Any] | None,
|
|
226
|
+
) -> str:
|
|
227
|
+
"""Apply the fixed-order verdict rules; first match wins."""
|
|
228
|
+
final_text = parsed["final_text"]
|
|
229
|
+
if summary is not None:
|
|
230
|
+
exit_code = summary.get("exit_code")
|
|
231
|
+
if (
|
|
232
|
+
(isinstance(exit_code, int) and exit_code != 0)
|
|
233
|
+
or bool(summary.get("timed_out"))
|
|
234
|
+
or bool(summary.get("idle_timed_out"))
|
|
235
|
+
or bool(summary.get("interrupted"))
|
|
236
|
+
):
|
|
237
|
+
return "error"
|
|
238
|
+
else:
|
|
239
|
+
if parsed["has_error_event"] and len(final_text) < EMPTY_FINAL_TEXT_THRESHOLD:
|
|
240
|
+
return "error"
|
|
241
|
+
if parsed["steps"] >= step_cap:
|
|
242
|
+
return "step_capped_partial"
|
|
243
|
+
if len(final_text) < EMPTY_FINAL_TEXT_THRESHOLD:
|
|
244
|
+
return "empty"
|
|
245
|
+
return "completed"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def build_verdict(
|
|
249
|
+
*,
|
|
250
|
+
run_id: str,
|
|
251
|
+
provider: str | None,
|
|
252
|
+
runner: str | None,
|
|
253
|
+
mode: str,
|
|
254
|
+
parsed: dict[str, Any],
|
|
255
|
+
summary: dict[str, Any] | None,
|
|
256
|
+
jsonl_path: str,
|
|
257
|
+
stderr_path: str | None,
|
|
258
|
+
step_cap: int,
|
|
259
|
+
) -> dict[str, Any]:
|
|
260
|
+
verdict = classify_verdict(parsed, step_cap, summary)
|
|
261
|
+
final_text = parsed["final_text"]
|
|
262
|
+
if summary is not None:
|
|
263
|
+
exit_code: Any = summary.get("exit_code")
|
|
264
|
+
timed_out = bool(summary.get("timed_out"))
|
|
265
|
+
idle_timed_out = bool(summary.get("idle_timed_out"))
|
|
266
|
+
interrupted = bool(summary.get("interrupted"))
|
|
267
|
+
session_id: Any = summary.get("session_id")
|
|
268
|
+
else:
|
|
269
|
+
exit_code = None
|
|
270
|
+
timed_out = False
|
|
271
|
+
idle_timed_out = False
|
|
272
|
+
interrupted = False
|
|
273
|
+
session_id = parsed.get("session_id")
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
"type": "worker_runner.verdict",
|
|
277
|
+
"schema_version": VERDICT_SCHEMA_VERSION,
|
|
278
|
+
"run_id": run_id,
|
|
279
|
+
"provider": provider,
|
|
280
|
+
"runner": runner,
|
|
281
|
+
"mode": mode,
|
|
282
|
+
"verdict": verdict,
|
|
283
|
+
"exit_code": exit_code,
|
|
284
|
+
"timed_out": timed_out,
|
|
285
|
+
"idle_timed_out": idle_timed_out,
|
|
286
|
+
"interrupted": interrupted,
|
|
287
|
+
"steps": parsed["steps"],
|
|
288
|
+
"step_cap": step_cap,
|
|
289
|
+
"duration_s": parsed["duration_s"],
|
|
290
|
+
"tokens": parsed["tokens"],
|
|
291
|
+
"tool_errors": parsed["tool_errors"],
|
|
292
|
+
"final_text_len": len(final_text),
|
|
293
|
+
"final_text": final_text,
|
|
294
|
+
"jsonl_path": jsonl_path,
|
|
295
|
+
"stderr_path": stderr_path,
|
|
296
|
+
"session_id": session_id,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def write_verdict_file(path: Path, verdict: dict[str, Any]) -> None:
|
|
301
|
+
"""Write the verdict JSON to disk with 0600 permissions."""
|
|
302
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
303
|
+
try:
|
|
304
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
305
|
+
handle.write(json.dumps(verdict, ensure_ascii=False))
|
|
306
|
+
handle.write("\n")
|
|
307
|
+
except Exception:
|
|
308
|
+
try:
|
|
309
|
+
os.close(fd)
|
|
310
|
+
except OSError:
|
|
311
|
+
pass
|
|
312
|
+
raise
|
|
313
|
+
try:
|
|
314
|
+
os.chmod(path, 0o600)
|
|
315
|
+
except OSError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Reparse mode
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def run_reparse(jsonl_arg: str, mode: str, runner_name: str = "opencode") -> int:
|
|
325
|
+
jsonl_path = Path(jsonl_arg).expanduser().resolve()
|
|
326
|
+
if not jsonl_path.is_file():
|
|
327
|
+
print(f"error: jsonl not found: {jsonl_path}", file=sys.stderr)
|
|
328
|
+
return DISPATCH_ERROR_EXIT
|
|
329
|
+
if mode not in policy.STEPS_BY_MODE:
|
|
330
|
+
print(f"error: unknown mode: {mode}", file=sys.stderr)
|
|
331
|
+
return DISPATCH_ERROR_EXIT
|
|
332
|
+
runner = get_runner(runner_name)
|
|
333
|
+
try:
|
|
334
|
+
parsed = parse_jsonl(jsonl_path, runner)
|
|
335
|
+
except OSError as exc:
|
|
336
|
+
print(f"error: cannot read jsonl: {exc}", file=sys.stderr)
|
|
337
|
+
return DISPATCH_ERROR_EXIT
|
|
338
|
+
run_id = jsonl_path.stem
|
|
339
|
+
verdict = build_verdict(
|
|
340
|
+
run_id=run_id,
|
|
341
|
+
provider=None,
|
|
342
|
+
runner=runner_name,
|
|
343
|
+
mode=mode,
|
|
344
|
+
parsed=parsed,
|
|
345
|
+
summary=None,
|
|
346
|
+
jsonl_path=str(jsonl_path),
|
|
347
|
+
stderr_path=None,
|
|
348
|
+
step_cap=policy.STEPS_BY_MODE[mode],
|
|
349
|
+
)
|
|
350
|
+
sys.stdout.write(json.dumps(verdict, ensure_ascii=False) + "\n")
|
|
351
|
+
sys.stdout.flush()
|
|
352
|
+
return 0
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Dispatch mode
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _validate_dispatch_args(
|
|
361
|
+
mode: str | None,
|
|
362
|
+
provider: str | None,
|
|
363
|
+
workdir: str | None,
|
|
364
|
+
task: str | None,
|
|
365
|
+
task_file: str | None,
|
|
366
|
+
) -> None:
|
|
367
|
+
if mode is None:
|
|
368
|
+
raise RuntimeError("--mode is required")
|
|
369
|
+
if provider is None:
|
|
370
|
+
raise RuntimeError("--provider is required")
|
|
371
|
+
if workdir is None:
|
|
372
|
+
raise RuntimeError("--workdir is required")
|
|
373
|
+
if task is None and task_file is None:
|
|
374
|
+
raise RuntimeError("one of --task or --task-file is required")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _build_runner_command(
|
|
378
|
+
provider: str,
|
|
379
|
+
mode: str,
|
|
380
|
+
workdir: str,
|
|
381
|
+
task: str | None,
|
|
382
|
+
task_file: str | None,
|
|
383
|
+
session: str | None,
|
|
384
|
+
worktree: bool,
|
|
385
|
+
timeout: int,
|
|
386
|
+
idle_timeout: int,
|
|
387
|
+
) -> list[str]:
|
|
388
|
+
cmd: list[str] = [
|
|
389
|
+
sys.executable,
|
|
390
|
+
"-m", "pilot_workers.cli.run",
|
|
391
|
+
"--provider",
|
|
392
|
+
provider,
|
|
393
|
+
"--mode",
|
|
394
|
+
mode,
|
|
395
|
+
"--workdir",
|
|
396
|
+
workdir,
|
|
397
|
+
]
|
|
398
|
+
if task is not None:
|
|
399
|
+
cmd.extend(["--task", task])
|
|
400
|
+
else:
|
|
401
|
+
assert task_file is not None
|
|
402
|
+
cmd.extend(["--task-file", task_file])
|
|
403
|
+
if session:
|
|
404
|
+
cmd.extend(["--session", session])
|
|
405
|
+
if worktree:
|
|
406
|
+
cmd.append("--worktree")
|
|
407
|
+
cmd.extend(["--timeout", str(timeout)])
|
|
408
|
+
cmd.extend(["--idle-timeout", str(idle_timeout)])
|
|
409
|
+
return cmd
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def run_dispatch(args: argparse.Namespace) -> int:
|
|
413
|
+
mode = args.mode
|
|
414
|
+
provider = getattr(args, "provider", None)
|
|
415
|
+
workdir = getattr(args, "workdir", None)
|
|
416
|
+
task = getattr(args, "task", None)
|
|
417
|
+
task_file = getattr(args, "task_file", None)
|
|
418
|
+
session = getattr(args, "session", None)
|
|
419
|
+
worktree = bool(getattr(args, "worktree", False))
|
|
420
|
+
timeout = getattr(args, "timeout", DEFAULT_TIMEOUT_S)
|
|
421
|
+
idle_timeout = getattr(args, "idle_timeout", DEFAULT_IDLE_TIMEOUT_S)
|
|
422
|
+
|
|
423
|
+
if getattr(args, "runner", "opencode") != "opencode":
|
|
424
|
+
print(
|
|
425
|
+
"error: --runner is only valid with --reparse; dispatch mode "
|
|
426
|
+
"determines the runner from the provider",
|
|
427
|
+
file=sys.stderr,
|
|
428
|
+
)
|
|
429
|
+
return DISPATCH_ERROR_EXIT
|
|
430
|
+
|
|
431
|
+
_validate_dispatch_args(mode, provider, workdir, task, task_file)
|
|
432
|
+
|
|
433
|
+
cmd = _build_runner_command(
|
|
434
|
+
provider, mode, workdir, task, task_file,
|
|
435
|
+
session, worktree, timeout, idle_timeout,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
started: dict[str, Any] | None = None
|
|
439
|
+
summary: dict[str, Any] | None = None
|
|
440
|
+
child_stderr_text = ""
|
|
441
|
+
with tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as child_stderr:
|
|
442
|
+
try:
|
|
443
|
+
proc = subprocess.Popen(
|
|
444
|
+
cmd,
|
|
445
|
+
stdout=subprocess.PIPE,
|
|
446
|
+
stderr=child_stderr,
|
|
447
|
+
text=True,
|
|
448
|
+
bufsize=1,
|
|
449
|
+
cwd=os.environ.get("TMPDIR") or "/tmp",
|
|
450
|
+
)
|
|
451
|
+
except OSError as exc:
|
|
452
|
+
print(f"error: cannot start runner: {exc}", file=sys.stderr)
|
|
453
|
+
return DISPATCH_ERROR_EXIT
|
|
454
|
+
|
|
455
|
+
assert proc.stdout is not None
|
|
456
|
+
try:
|
|
457
|
+
for raw_line in proc.stdout:
|
|
458
|
+
line = raw_line.rstrip("\n")
|
|
459
|
+
if not line:
|
|
460
|
+
continue
|
|
461
|
+
try:
|
|
462
|
+
event = json.loads(line)
|
|
463
|
+
except json.JSONDecodeError:
|
|
464
|
+
continue
|
|
465
|
+
if not isinstance(event, dict):
|
|
466
|
+
continue
|
|
467
|
+
etype = event.get("type")
|
|
468
|
+
if etype == "worker_runner.started" and started is None:
|
|
469
|
+
started = event
|
|
470
|
+
sys.stdout.write(line + "\n")
|
|
471
|
+
sys.stdout.flush()
|
|
472
|
+
elif etype == "worker_runner.summary":
|
|
473
|
+
summary = event
|
|
474
|
+
proc.wait()
|
|
475
|
+
except KeyboardInterrupt:
|
|
476
|
+
proc.terminate()
|
|
477
|
+
try:
|
|
478
|
+
proc.wait(timeout=10)
|
|
479
|
+
except subprocess.TimeoutExpired:
|
|
480
|
+
proc.kill()
|
|
481
|
+
proc.wait()
|
|
482
|
+
print("error: interrupted", file=sys.stderr)
|
|
483
|
+
return DISPATCH_ERROR_EXIT
|
|
484
|
+
|
|
485
|
+
child_stderr.flush()
|
|
486
|
+
child_stderr.seek(0)
|
|
487
|
+
child_stderr_text = child_stderr.read()
|
|
488
|
+
|
|
489
|
+
if started is None:
|
|
490
|
+
print(
|
|
491
|
+
"error: runner never emitted worker_runner.started",
|
|
492
|
+
file=sys.stderr,
|
|
493
|
+
)
|
|
494
|
+
if child_stderr_text.strip():
|
|
495
|
+
print(child_stderr_text.rstrip(), file=sys.stderr)
|
|
496
|
+
return DISPATCH_ERROR_EXIT
|
|
497
|
+
|
|
498
|
+
log_path_str = started.get("log")
|
|
499
|
+
stderr_log_str = started.get("stderr_log")
|
|
500
|
+
run_id = started.get("run_id")
|
|
501
|
+
provider_from_started = started.get("provider")
|
|
502
|
+
runner_name = started.get("runner") or "opencode"
|
|
503
|
+
if not isinstance(log_path_str, str) or not isinstance(run_id, str):
|
|
504
|
+
print("error: started event missing log/run_id", file=sys.stderr)
|
|
505
|
+
return DISPATCH_ERROR_EXIT
|
|
506
|
+
|
|
507
|
+
log_path = Path(log_path_str)
|
|
508
|
+
if not log_path.is_file():
|
|
509
|
+
print(f"error: jsonl not found: {log_path}", file=sys.stderr)
|
|
510
|
+
return DISPATCH_ERROR_EXIT
|
|
511
|
+
runner = get_runner(runner_name)
|
|
512
|
+
try:
|
|
513
|
+
parsed = parse_jsonl(log_path, runner)
|
|
514
|
+
except OSError as exc:
|
|
515
|
+
print(f"error: cannot read jsonl: {exc}", file=sys.stderr)
|
|
516
|
+
return DISPATCH_ERROR_EXIT
|
|
517
|
+
|
|
518
|
+
step_cap = policy.STEPS_BY_MODE.get(mode, 0)
|
|
519
|
+
verdict = build_verdict(
|
|
520
|
+
run_id=run_id,
|
|
521
|
+
provider=provider_from_started,
|
|
522
|
+
runner=runner_name,
|
|
523
|
+
mode=mode,
|
|
524
|
+
parsed=parsed,
|
|
525
|
+
summary=summary,
|
|
526
|
+
jsonl_path=str(log_path),
|
|
527
|
+
stderr_path=stderr_log_str,
|
|
528
|
+
step_cap=step_cap,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
verdict_path = log_path.parent / f"{run_id}.verdict.json"
|
|
532
|
+
try:
|
|
533
|
+
write_verdict_file(verdict_path, verdict)
|
|
534
|
+
except OSError as exc:
|
|
535
|
+
print(f"error: cannot write verdict file: {exc}", file=sys.stderr)
|
|
536
|
+
return DISPATCH_ERROR_EXIT
|
|
537
|
+
|
|
538
|
+
sys.stdout.write(json.dumps(verdict, ensure_ascii=False) + "\n")
|
|
539
|
+
sys.stdout.flush()
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
# Entry point
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def main(argv: list[str] | None = None) -> int:
|
|
549
|
+
args = parse_args(argv)
|
|
550
|
+
namespace_dict = vars(args)
|
|
551
|
+
has_reparse = namespace_dict.get("reparse") is not None
|
|
552
|
+
present_dispatch_args = [n for n in DISPATCH_ARG_NAMES if n in namespace_dict]
|
|
553
|
+
|
|
554
|
+
if has_reparse:
|
|
555
|
+
if present_dispatch_args:
|
|
556
|
+
listed = ", ".join(
|
|
557
|
+
"--" + n.replace("_", "-") for n in present_dispatch_args
|
|
558
|
+
)
|
|
559
|
+
print(
|
|
560
|
+
f"error: --reparse cannot be combined with dispatch arguments: {listed}",
|
|
561
|
+
file=sys.stderr,
|
|
562
|
+
)
|
|
563
|
+
return DISPATCH_ERROR_EXIT
|
|
564
|
+
if args.mode is None:
|
|
565
|
+
print("error: --mode is required with --reparse", file=sys.stderr)
|
|
566
|
+
return DISPATCH_ERROR_EXIT
|
|
567
|
+
return run_reparse(args.reparse, args.mode, args.runner)
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
return run_dispatch(args)
|
|
571
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
572
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
573
|
+
return DISPATCH_ERROR_EXIT
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
if __name__ == "__main__":
|
|
577
|
+
raise SystemExit(main())
|