pi-multi-agent-skill 0.0.0-stage → 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +147 -2
- package/package.json +32 -4
- package/skills/multi-agent/SKILL.md +140 -0
- package/skills/multi-agent/profiles/Explore.md +34 -0
- package/skills/multi-agent/profiles/general-purpose.md +3 -0
- package/skills/multi-agent/references/commands.md +218 -0
- package/skills/multi-agent/references/lifecycle.md +92 -0
- package/skills/multi-agent/references/profiles.md +79 -0
- package/skills/multi-agent/references/state.md +57 -0
- package/skills/multi-agent/references/troubleshooting.md +59 -0
- package/skills/multi-agent/scripts/multi_agent.py +2100 -0
|
@@ -0,0 +1,2100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# multi_agent.py - start pi agents as separate `pi --mode rpc` processes and
|
|
3
|
+
# manage each one as a named job.
|
|
4
|
+
#
|
|
5
|
+
# ../SKILL.md explains how an agent uses this script; ../references/ describes
|
|
6
|
+
# every command, option, status and state file.
|
|
7
|
+
#
|
|
8
|
+
# Stdlib only, Python 3.9+. Invoke as `python3 multi_agent.py ...` rather than
|
|
9
|
+
# relying on the exec bit.
|
|
10
|
+
|
|
11
|
+
import calendar
|
|
12
|
+
import fcntl
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
import selectors
|
|
20
|
+
import shlex
|
|
21
|
+
import shutil
|
|
22
|
+
import signal
|
|
23
|
+
import socket
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
import traceback
|
|
29
|
+
|
|
30
|
+
# Prompts, job state and agent output are UTF-8 whatever the caller's locale.
|
|
31
|
+
for _stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
32
|
+
try:
|
|
33
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
34
|
+
except (AttributeError, ValueError, OSError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
SCRIPT_PATH = os.path.abspath(__file__)
|
|
38
|
+
SKILL_DIR = os.path.dirname(os.path.dirname(SCRIPT_PATH))
|
|
39
|
+
BUILTIN_PROFILES_DIR = os.path.join(SKILL_DIR, "profiles")
|
|
40
|
+
|
|
41
|
+
RESULT_DELIMITER = "--- multi-agent ---"
|
|
42
|
+
DEFAULT_PROFILE = "general-purpose"
|
|
43
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$")
|
|
44
|
+
DIALOG_METHODS = ("select", "confirm", "input", "editor")
|
|
45
|
+
STOP_SIGNALS = tuple(
|
|
46
|
+
getattr(signal, n) for n in ("SIGTERM", "SIGHUP", "SIGINT") if hasattr(signal, n)
|
|
47
|
+
)
|
|
48
|
+
# Removed from the agent's environment so the herdr extension inside the agent
|
|
49
|
+
# does not report state for the caller's pane.
|
|
50
|
+
REMOVED_ENV = ("HERDR_ENV", "HERDR_PANE_ID", "HERDR_SOCKET_PATH")
|
|
51
|
+
NOT_RUNNING = "job is not running; use resume"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def time_scale():
|
|
55
|
+
"""The factor for the timing constants below; tests set it to make them short."""
|
|
56
|
+
value = os.environ.get("PI_MULTI_AGENT_TIME_SCALE") or "1"
|
|
57
|
+
try:
|
|
58
|
+
scale = float(value)
|
|
59
|
+
except ValueError:
|
|
60
|
+
scale = 0.0
|
|
61
|
+
if not 0 < scale < float("inf"):
|
|
62
|
+
raise SystemExit(
|
|
63
|
+
f"multi-agent: error: PI_MULTI_AGENT_TIME_SCALE needs a positive number, got: {value}"
|
|
64
|
+
)
|
|
65
|
+
return scale
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
TIME_SCALE = time_scale()
|
|
69
|
+
BACKGROUND_TIMEOUT_S = 3600
|
|
70
|
+
DEFAULT_MAX_DEPTH = 2
|
|
71
|
+
ACK_TIMEOUT_S = 10 * TIME_SCALE
|
|
72
|
+
SETTLE_TIMEOUT_S = 30 * TIME_SCALE
|
|
73
|
+
PI_EXIT_GRACE_S = 10 * TIME_SCALE
|
|
74
|
+
PI_TERM_GRACE_S = 5 * TIME_SCALE
|
|
75
|
+
# After the runner accepts `kill`, the command waits this long for it to
|
|
76
|
+
# exit: the runner's whole stop sequence for pi, plus a margin.
|
|
77
|
+
RUNNER_STOP_GRACE_S = PI_EXIT_GRACE_S + PI_TERM_GRACE_S + 5 * TIME_SCALE
|
|
78
|
+
# Starters hold the job's lock for a moment before they hand it to the
|
|
79
|
+
# runner, and readers hold it shared for a moment, so a command that wants it
|
|
80
|
+
# exclusively waits this long before it decides that a runner holds it.
|
|
81
|
+
LOCK_TIMEOUT_S = 5 * TIME_SCALE
|
|
82
|
+
# How often `wait` checks whether the job has ended.
|
|
83
|
+
WAIT_POLL_S = 0.5 * TIME_SCALE
|
|
84
|
+
# sun_path holds the socket path and its trailing NUL.
|
|
85
|
+
SUN_PATH_MAX = 104 if sys.platform == "darwin" or "bsd" in sys.platform else 108
|
|
86
|
+
RECENT_S = 24 * 3600
|
|
87
|
+
|
|
88
|
+
EXIT_OK = 0
|
|
89
|
+
EXIT_FAIL = 1
|
|
90
|
+
EXIT_STILL_RUNNING = 2
|
|
91
|
+
EXIT_USAGE = 64
|
|
92
|
+
|
|
93
|
+
MULTI_AGENT_BLOCK = """<multi-agent>
|
|
94
|
+
You are an agent named {name}, started by another agent to work on one
|
|
95
|
+
task. Whoever reads your result sees only your final message, so make it a
|
|
96
|
+
complete, self-contained answer to the task. No person is available to
|
|
97
|
+
answer questions. If a tool call fails because it needs a person, do not
|
|
98
|
+
retry it; say in your final message what you could not do and why.
|
|
99
|
+
</multi-agent>
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
USAGE = """\
|
|
103
|
+
Usage:
|
|
104
|
+
multi_agent.py run [options] (PROMPT | -)
|
|
105
|
+
multi_agent.py resume NAME [--background] [--timeout S] (PROMPT | -)
|
|
106
|
+
multi_agent.py wait NAME [--timeout S]
|
|
107
|
+
multi_agent.py result NAME
|
|
108
|
+
multi_agent.py steer NAME (MESSAGE | -)
|
|
109
|
+
multi_agent.py abort NAME
|
|
110
|
+
multi_agent.py kill (NAME | --all)
|
|
111
|
+
multi_agent.py status NAME
|
|
112
|
+
multi_agent.py peek NAME [-n N]
|
|
113
|
+
multi_agent.py list [--all]
|
|
114
|
+
multi_agent.py profiles
|
|
115
|
+
multi_agent.py rm (NAME | --all)
|
|
116
|
+
|
|
117
|
+
Options for run:
|
|
118
|
+
--profile P Profile name, matched case-insensitively (default general-purpose)
|
|
119
|
+
--name N Job name (default <profile>-<4 hex digits>)
|
|
120
|
+
--background Detach the runner and return immediately
|
|
121
|
+
--timeout S Wall-clock limit in seconds (default 3600 in the background,
|
|
122
|
+
none in the foreground; 0 means none)
|
|
123
|
+
--fork Start from a copy of the caller's conversation ($PI_SESSION_FILE)
|
|
124
|
+
--model M Model pattern passed to `pi --model`
|
|
125
|
+
--thinking L Thinking level passed to `pi --thinking`
|
|
126
|
+
--cwd DIR Working directory for the agent (default: the current directory)
|
|
127
|
+
|
|
128
|
+
A PROMPT or MESSAGE of `-` is read from stdin. See references/commands.md for details."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CommandError(Exception):
|
|
132
|
+
def __init__(self, message, code=EXIT_FAIL):
|
|
133
|
+
Exception.__init__(self, message)
|
|
134
|
+
self.code = code
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class NotRunning(CommandError):
|
|
138
|
+
def __init__(self):
|
|
139
|
+
CommandError.__init__(self, NOT_RUNNING)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def usage_error(message):
|
|
143
|
+
return CommandError(message, EXIT_USAGE)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def note(message):
|
|
147
|
+
sys.stderr.write(f"multi-agent: {message}\n")
|
|
148
|
+
sys.stderr.flush()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- small helpers ---------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def utcnow():
|
|
155
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_utc(text):
|
|
159
|
+
try:
|
|
160
|
+
return calendar.timegm(time.strptime(text.strip(), "%Y-%m-%dT%H:%M:%SZ"))
|
|
161
|
+
except (ValueError, AttributeError):
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def read_text(path, default=None):
|
|
166
|
+
try:
|
|
167
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
168
|
+
return fh.read()
|
|
169
|
+
except OSError:
|
|
170
|
+
return default
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def read_json(path, default=None):
|
|
174
|
+
text = read_text(path)
|
|
175
|
+
if text is None:
|
|
176
|
+
return default
|
|
177
|
+
try:
|
|
178
|
+
return json.loads(text)
|
|
179
|
+
except ValueError:
|
|
180
|
+
return default
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def read_int(path):
|
|
184
|
+
try:
|
|
185
|
+
return int((read_text(path) or "").strip())
|
|
186
|
+
except ValueError:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def write_atomic(path, text):
|
|
191
|
+
"""Write a state file through a temporary file and a rename."""
|
|
192
|
+
tmp = os.path.join(
|
|
193
|
+
os.path.dirname(path),
|
|
194
|
+
f".{os.path.basename(path)}.tmp-{os.getpid()}-{threading.get_ident()}",
|
|
195
|
+
)
|
|
196
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
197
|
+
fh.write(text)
|
|
198
|
+
os.replace(tmp, path)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def remove(path):
|
|
202
|
+
try:
|
|
203
|
+
os.remove(path)
|
|
204
|
+
except OSError:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def wait_while(check, timeout):
|
|
209
|
+
"""Poll check() until it is false; False if it is still true after timeout seconds."""
|
|
210
|
+
end = time.monotonic() + timeout
|
|
211
|
+
while check():
|
|
212
|
+
if time.monotonic() >= end:
|
|
213
|
+
return False
|
|
214
|
+
time.sleep(0.1)
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def format_age(seconds):
|
|
219
|
+
seconds = max(0, int(seconds))
|
|
220
|
+
if seconds < 60:
|
|
221
|
+
return f"{seconds}s"
|
|
222
|
+
if seconds < 3600:
|
|
223
|
+
return f"{seconds // 60}m"
|
|
224
|
+
if seconds < 86400:
|
|
225
|
+
return f"{seconds // 3600}h"
|
|
226
|
+
return f"{seconds // 86400}d"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def excerpt(text, limit=120):
|
|
230
|
+
text = " ".join(str(text).split())
|
|
231
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# --- locations ---------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def logical_cwd():
|
|
238
|
+
# Use $PWD when it names the current directory, so symlinked paths keep
|
|
239
|
+
# the name the caller used and the project slug stays stable.
|
|
240
|
+
pwd = os.environ.get("PWD")
|
|
241
|
+
if pwd and os.path.isabs(pwd):
|
|
242
|
+
try:
|
|
243
|
+
if os.path.samefile(pwd, "."):
|
|
244
|
+
return pwd
|
|
245
|
+
except OSError:
|
|
246
|
+
pass
|
|
247
|
+
return os.getcwd()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def project_slug(path):
|
|
251
|
+
return re.sub(r"[^A-Za-z0-9]", "-", path).lstrip("-")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def state_root():
|
|
255
|
+
base = os.environ.get("XDG_STATE_HOME") or os.path.join(
|
|
256
|
+
os.path.expanduser("~"), ".local", "state"
|
|
257
|
+
)
|
|
258
|
+
return os.path.abspath(os.path.join(base, "pi-multi-agent"))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def pi_agent_dir():
|
|
262
|
+
return os.path.expanduser(
|
|
263
|
+
os.environ.get("PI_CODING_AGENT_DIR") or os.path.join("~", ".pi", "agent")
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def jobs_dir(project):
|
|
268
|
+
return os.path.join(state_root(), project_slug(project), "jobs")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def sessions_dir(project):
|
|
272
|
+
return os.path.join(pi_agent_dir(), "multi-agent-sessions", project_slug(project))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def pi_bin():
|
|
276
|
+
return os.environ.get("PI_MULTI_AGENT_PI_BIN") or "pi"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_pi_bin():
|
|
280
|
+
if not shutil.which(pi_bin()):
|
|
281
|
+
raise CommandError(
|
|
282
|
+
f'cannot find the pi program "{pi_bin()}"; '
|
|
283
|
+
"set PI_MULTI_AGENT_PI_BIN to the command that starts pi"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def find_session_file(session_dir, name):
|
|
288
|
+
"""pi names a session file `<timestamp>_<id>.jsonl`; the timestamp has no `_`."""
|
|
289
|
+
pattern = re.compile(r"^[^_]+_" + re.escape(name) + r"\.jsonl$")
|
|
290
|
+
try:
|
|
291
|
+
entries = sorted(os.listdir(session_dir))
|
|
292
|
+
except OSError:
|
|
293
|
+
return []
|
|
294
|
+
return [os.path.join(session_dir, e) for e in entries if pattern.match(e)]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# --- profiles ----------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def split_items(text):
|
|
301
|
+
"""Split a comma-separated list, keeping commas inside quotes."""
|
|
302
|
+
items, current, quote = [], "", None
|
|
303
|
+
for ch in text:
|
|
304
|
+
if quote:
|
|
305
|
+
current += ch
|
|
306
|
+
if ch == quote:
|
|
307
|
+
quote = None
|
|
308
|
+
elif ch in "\"'":
|
|
309
|
+
quote = ch
|
|
310
|
+
current += ch
|
|
311
|
+
elif ch == ",":
|
|
312
|
+
items.append(current)
|
|
313
|
+
current = ""
|
|
314
|
+
else:
|
|
315
|
+
current += ch
|
|
316
|
+
items.append(current)
|
|
317
|
+
return [item.strip() for item in items if item.strip()]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def parse_scalar(raw):
|
|
321
|
+
value = raw.strip()
|
|
322
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
323
|
+
try:
|
|
324
|
+
return json.loads(value)
|
|
325
|
+
except ValueError:
|
|
326
|
+
return value[1:-1]
|
|
327
|
+
if len(value) >= 2 and value[0] == value[-1] == "'":
|
|
328
|
+
return value[1:-1].replace("''", "'")
|
|
329
|
+
if value.startswith("[") and value.endswith("]"):
|
|
330
|
+
return [parse_scalar(item) for item in split_items(value[1:-1])]
|
|
331
|
+
if value.lower() == "true":
|
|
332
|
+
return True
|
|
333
|
+
if value.lower() == "false":
|
|
334
|
+
return False
|
|
335
|
+
return value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def parse_frontmatter(text):
|
|
339
|
+
"""Return (fields, body) for a Markdown file with `---` frontmatter.
|
|
340
|
+
|
|
341
|
+
Supports `key: value` lines, quoted strings, true/false and `[a, b]`
|
|
342
|
+
lists. Indented lines (nested YAML) and comments are skipped.
|
|
343
|
+
"""
|
|
344
|
+
lines = text.lstrip("").splitlines()
|
|
345
|
+
if not lines or lines[0].strip() != "---":
|
|
346
|
+
return {}, text.strip()
|
|
347
|
+
end = None
|
|
348
|
+
for i in range(1, len(lines)):
|
|
349
|
+
if lines[i].strip() == "---":
|
|
350
|
+
end = i
|
|
351
|
+
break
|
|
352
|
+
if end is None:
|
|
353
|
+
return {}, text.strip()
|
|
354
|
+
fields = {}
|
|
355
|
+
for line in lines[1:end]:
|
|
356
|
+
if not line.strip() or line.lstrip().startswith("#") or line[0] in " \t":
|
|
357
|
+
continue
|
|
358
|
+
key, sep, value = line.partition(":")
|
|
359
|
+
if sep and key.strip():
|
|
360
|
+
fields[key.strip()] = parse_scalar(value)
|
|
361
|
+
return fields, "\n".join(lines[end + 1 :]).strip()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def tool_value(value):
|
|
365
|
+
"""Join a tools field into pi's comma-separated form; None when unset or empty."""
|
|
366
|
+
if value is None or value is True or value is False:
|
|
367
|
+
return None
|
|
368
|
+
items = value if isinstance(value, list) else split_items(str(value))
|
|
369
|
+
joined = ",".join(str(item).strip() for item in items if str(item).strip())
|
|
370
|
+
return joined or None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def text_field(fields, key):
|
|
374
|
+
value = fields.get(key)
|
|
375
|
+
if value is None or isinstance(value, (bool, list)):
|
|
376
|
+
return None
|
|
377
|
+
value = str(value).strip()
|
|
378
|
+
return value or None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def make_profile(name, fields, body, source, scope):
|
|
382
|
+
return {
|
|
383
|
+
"name": name,
|
|
384
|
+
"description": text_field(fields, "description") or name,
|
|
385
|
+
"model": text_field(fields, "model"),
|
|
386
|
+
"thinking": text_field(fields, "thinking"),
|
|
387
|
+
"tools": tool_value(fields.get("tools")),
|
|
388
|
+
"disallowed_tools": tool_value(fields.get("disallowed_tools")),
|
|
389
|
+
"prompt_mode": (text_field(fields, "prompt_mode") or "append").lower(),
|
|
390
|
+
"enabled": fields.get("enabled") is not False,
|
|
391
|
+
"body": body,
|
|
392
|
+
"source": source,
|
|
393
|
+
"scope": scope,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def profile_dirs(cwd):
|
|
398
|
+
return [
|
|
399
|
+
("project", os.path.join(cwd, ".pi", "agents")),
|
|
400
|
+
("user", os.path.join(pi_agent_dir(), "agents")),
|
|
401
|
+
("builtin", BUILTIN_PROFILES_DIR),
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def load_profiles(cwd):
|
|
406
|
+
"""Map each lowercased profile name to the profile from the highest directory.
|
|
407
|
+
|
|
408
|
+
A profile replaces lower ones of the same name as a whole; a disabled one
|
|
409
|
+
hides them. Disabled entries stay in the map with enabled False.
|
|
410
|
+
"""
|
|
411
|
+
found = {}
|
|
412
|
+
for scope, directory in profile_dirs(cwd):
|
|
413
|
+
try:
|
|
414
|
+
entries = sorted(os.listdir(directory))
|
|
415
|
+
except OSError:
|
|
416
|
+
continue
|
|
417
|
+
for entry in entries:
|
|
418
|
+
path = os.path.join(directory, entry)
|
|
419
|
+
if not entry.endswith(".md") or not os.path.isfile(path):
|
|
420
|
+
continue
|
|
421
|
+
text = read_text(path)
|
|
422
|
+
if text is None:
|
|
423
|
+
continue
|
|
424
|
+
fields, body = parse_frontmatter(text)
|
|
425
|
+
name = text_field(fields, "name") or entry[: -len(".md")]
|
|
426
|
+
found.setdefault(
|
|
427
|
+
name.lower(), make_profile(name, fields, body, path, scope)
|
|
428
|
+
)
|
|
429
|
+
return found
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def find_profile(cwd, name):
|
|
433
|
+
profiles = load_profiles(cwd)
|
|
434
|
+
profile = profiles.get(name.lower())
|
|
435
|
+
if profile is None or not profile["enabled"]:
|
|
436
|
+
available = sorted(p["name"] for p in profiles.values() if p["enabled"])
|
|
437
|
+
raise CommandError(
|
|
438
|
+
"unknown profile: {} (available: {})".format(
|
|
439
|
+
name, ", ".join(available) or "none"
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
if profile["prompt_mode"] not in ("append", "replace"):
|
|
443
|
+
raise CommandError(
|
|
444
|
+
"profile {} in {}: prompt_mode must be append or replace, got {}".format(
|
|
445
|
+
profile["name"], profile["source"], profile["prompt_mode"]
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
return profile
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def caller_model():
|
|
452
|
+
model = os.environ.get("PI_MODEL") or ""
|
|
453
|
+
provider = os.environ.get("PI_PROVIDER") or ""
|
|
454
|
+
if model and provider:
|
|
455
|
+
return provider + "/" + model
|
|
456
|
+
return model or None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def resolve_model(option, profile):
|
|
460
|
+
return option or profile["model"] or caller_model()
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def resolve_thinking(option, profile):
|
|
464
|
+
return option or profile["thinking"] or os.environ.get("PI_REASONING_LEVEL") or None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def profile_args(profile, model, thinking, prompt_file):
|
|
468
|
+
"""pi arguments for a profile. In replace mode, writes the profile's prompt to prompt_file.
|
|
469
|
+
|
|
470
|
+
In append mode, the prompt reaches pi through append_prompt() instead.
|
|
471
|
+
"""
|
|
472
|
+
args = []
|
|
473
|
+
if model:
|
|
474
|
+
args += ["--model", model]
|
|
475
|
+
if thinking:
|
|
476
|
+
args += ["--thinking", thinking]
|
|
477
|
+
if profile["tools"]:
|
|
478
|
+
args += ["--tools", profile["tools"]]
|
|
479
|
+
if profile["disallowed_tools"]:
|
|
480
|
+
args += ["-xt", profile["disallowed_tools"]]
|
|
481
|
+
if profile["prompt_mode"] == "replace":
|
|
482
|
+
write_atomic(prompt_file, profile["body"] + "\n")
|
|
483
|
+
args += ["--system-prompt", prompt_file, "-nc"]
|
|
484
|
+
return args
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def append_prompt(profile, name):
|
|
488
|
+
"""Text passed with --append-system-prompt: the profile's prompt in append
|
|
489
|
+
mode, then the multi-agent block."""
|
|
490
|
+
parts = []
|
|
491
|
+
if profile["prompt_mode"] == "append" and profile["body"]:
|
|
492
|
+
parts.append(profile["body"])
|
|
493
|
+
parts.append(MULTI_AGENT_BLOCK.format(name=name).strip())
|
|
494
|
+
return "\n\n".join(parts) + "\n"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --- job state ---------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def validate_name(name):
|
|
501
|
+
if not NAME_RE.match(name):
|
|
502
|
+
raise usage_error(
|
|
503
|
+
f"invalid job name: {name!r} (use letters, digits, '.', '_' and '-', start and end with a"
|
|
504
|
+
" letter or digit, at most 64 characters)"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def default_name(profile_name):
|
|
509
|
+
base = (
|
|
510
|
+
re.sub(r"[^a-z0-9._-]+", "-", profile_name.lower()).strip("._-")[:59] or "agent"
|
|
511
|
+
)
|
|
512
|
+
return f"{base}-{secrets.token_hex(2)}"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def job_path(job_dir, name):
|
|
516
|
+
return os.path.join(job_dir, name)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def state_dir_of(job_dir):
|
|
520
|
+
"""The state root that holds job_dir (`<root>/<project>/jobs/<name>`)."""
|
|
521
|
+
return os.path.dirname(os.path.dirname(os.path.dirname(job_dir)))
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def job_lock(job_dir, busy_message):
|
|
525
|
+
"""Take the job's lock exclusively and return its fd.
|
|
526
|
+
|
|
527
|
+
The lock is an flock on the file `lock`. The runner holds it for its
|
|
528
|
+
whole life, so the job is running exactly while the lock is held (see
|
|
529
|
+
job_state). A starter takes it, then hands its fd to the runner it
|
|
530
|
+
starts; `rm` takes it to delete the job. Readers hold it shared for a
|
|
531
|
+
moment, so the lock is retried for LOCK_TIMEOUT_S before this fails
|
|
532
|
+
with busy_message.
|
|
533
|
+
"""
|
|
534
|
+
name = os.path.basename(job_dir)
|
|
535
|
+
path = job_path(job_dir, "lock")
|
|
536
|
+
try:
|
|
537
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
538
|
+
except FileNotFoundError:
|
|
539
|
+
raise CommandError(f"job {name} was removed")
|
|
540
|
+
try:
|
|
541
|
+
end = time.monotonic() + LOCK_TIMEOUT_S
|
|
542
|
+
while True:
|
|
543
|
+
try:
|
|
544
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
545
|
+
break
|
|
546
|
+
except BlockingIOError:
|
|
547
|
+
if time.monotonic() >= end:
|
|
548
|
+
raise CommandError(busy_message)
|
|
549
|
+
time.sleep(0.05)
|
|
550
|
+
except OSError as exc:
|
|
551
|
+
raise CommandError(
|
|
552
|
+
f"flock does not work on {state_dir_of(job_dir)} ({exc.strerror or exc}); set XDG_STATE_HOME to a local file system"
|
|
553
|
+
)
|
|
554
|
+
# `rm` may have removed the job while this command waited.
|
|
555
|
+
try:
|
|
556
|
+
if os.stat(path).st_ino != os.fstat(fd).st_ino:
|
|
557
|
+
raise CommandError(f"job {name} was removed")
|
|
558
|
+
except FileNotFoundError:
|
|
559
|
+
raise CommandError(f"job {name} was removed")
|
|
560
|
+
except BaseException:
|
|
561
|
+
os.close(fd)
|
|
562
|
+
raise
|
|
563
|
+
return fd
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def job_state(job_dir):
|
|
567
|
+
"""The job's status: `running` while its lock is held, else the end its runner recorded.
|
|
568
|
+
|
|
569
|
+
The files are read under a shared lock, so no run can start while they
|
|
570
|
+
are read, and the reading is a consistent snapshot. A run without an
|
|
571
|
+
exit code ended without its runner recording a result: `died`.
|
|
572
|
+
"""
|
|
573
|
+
try:
|
|
574
|
+
fd = os.open(job_path(job_dir, "lock"), os.O_RDONLY)
|
|
575
|
+
except OSError:
|
|
576
|
+
fd = None
|
|
577
|
+
try:
|
|
578
|
+
if fd is not None:
|
|
579
|
+
try:
|
|
580
|
+
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
581
|
+
except BlockingIOError:
|
|
582
|
+
return "running"
|
|
583
|
+
except OSError:
|
|
584
|
+
# flock does not work here, so `run` never started a job here.
|
|
585
|
+
pass
|
|
586
|
+
if not os.path.exists(job_path(job_dir, "exit_code")):
|
|
587
|
+
return "died"
|
|
588
|
+
return (read_text(job_path(job_dir, "status"), "") or "").strip() or "unknown"
|
|
589
|
+
finally:
|
|
590
|
+
if fd is not None:
|
|
591
|
+
os.close(fd)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def job_since(job_dir, status):
|
|
595
|
+
"""Return the time used for the job's age, or None when it is missing."""
|
|
596
|
+
started = parse_utc(read_text(job_path(job_dir, "started_at"), "") or "")
|
|
597
|
+
if status == "running":
|
|
598
|
+
return started
|
|
599
|
+
ended = parse_utc(read_text(job_path(job_dir, "ended_at"), "") or "")
|
|
600
|
+
return ended or started
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def socket_path(job_dir):
|
|
604
|
+
"""The path of the job's control socket.
|
|
605
|
+
|
|
606
|
+
It is computed from the job directory, so every command finds it without
|
|
607
|
+
a file that maps names to sockets. The hash keeps its length fixed:
|
|
608
|
+
the state root plus about 30 bytes.
|
|
609
|
+
"""
|
|
610
|
+
digest = hashlib.sha256(os.fsencode(job_dir)).hexdigest()[:16]
|
|
611
|
+
return os.path.join(state_dir_of(job_dir), "sockets", digest + ".sock")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def check_socket_path(job_dir):
|
|
615
|
+
if len(os.fsencode(socket_path(job_dir))) >= SUN_PATH_MAX:
|
|
616
|
+
raise CommandError(
|
|
617
|
+
"the socket path for this job is too long; set XDG_STATE_HOME to a shorter path"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def listen_control(job_dir):
|
|
622
|
+
"""Bind and listen on the job's control socket; called with the job's lock held.
|
|
623
|
+
|
|
624
|
+
A leftover socket file belongs to a runner that is gone, since the lock
|
|
625
|
+
is free, so it is replaced.
|
|
626
|
+
"""
|
|
627
|
+
check_socket_path(job_dir)
|
|
628
|
+
path = socket_path(job_dir)
|
|
629
|
+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
630
|
+
remove(path)
|
|
631
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
632
|
+
try:
|
|
633
|
+
server.bind(path)
|
|
634
|
+
server.listen(64)
|
|
635
|
+
except OSError as exc:
|
|
636
|
+
server.close()
|
|
637
|
+
raise CommandError(f"could not create the control socket {path}: {exc}")
|
|
638
|
+
return server
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def find_job(name):
|
|
642
|
+
"""Look a job up in the current project, then in all projects."""
|
|
643
|
+
validate_name(name)
|
|
644
|
+
direct = os.path.join(jobs_dir(logical_cwd()), name)
|
|
645
|
+
if os.path.isdir(direct):
|
|
646
|
+
return direct
|
|
647
|
+
matches = []
|
|
648
|
+
try:
|
|
649
|
+
projects = sorted(os.listdir(state_root()))
|
|
650
|
+
except OSError:
|
|
651
|
+
projects = []
|
|
652
|
+
for project in projects:
|
|
653
|
+
candidate = os.path.join(state_root(), project, "jobs", name)
|
|
654
|
+
if os.path.isdir(candidate):
|
|
655
|
+
matches.append(candidate)
|
|
656
|
+
if len(matches) == 1:
|
|
657
|
+
return matches[0]
|
|
658
|
+
if matches:
|
|
659
|
+
raise CommandError(
|
|
660
|
+
"job {} exists in several projects; run the command from the project's directory:\n {}".format(
|
|
661
|
+
name, "\n ".join(matches)
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
raise CommandError(f"unknown job: {name}")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def model_label(job_dir, job):
|
|
668
|
+
used = (read_text(job_path(job_dir, "model"), "") or "").strip()
|
|
669
|
+
return used or job.get("model") or "(pi default)"
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def duration_s(job_dir):
|
|
673
|
+
started = parse_utc(read_text(job_path(job_dir, "started_at"), "") or "")
|
|
674
|
+
ended = parse_utc(read_text(job_path(job_dir, "ended_at"), "") or "")
|
|
675
|
+
if started is None:
|
|
676
|
+
return None
|
|
677
|
+
return int((ended if ended is not None else time.time()) - started)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def format_result(job_dir, status):
|
|
681
|
+
job = read_json(job_path(job_dir, "job.json"), {})
|
|
682
|
+
output = (read_text(job_path(job_dir, "output.txt"), "") or "").rstrip("\n")
|
|
683
|
+
duration = duration_s(job_dir)
|
|
684
|
+
return "{}\n\n{}\nname: {}\nstatus: {}\nprofile: {}\nmodel: {}\nduration_s: {}\n".format(
|
|
685
|
+
output if output.strip() else "(no output)",
|
|
686
|
+
RESULT_DELIMITER,
|
|
687
|
+
os.path.basename(job_dir),
|
|
688
|
+
status,
|
|
689
|
+
job.get("profile", "?"),
|
|
690
|
+
model_label(job_dir, job),
|
|
691
|
+
"?" if duration is None else duration,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def print_result(job_dir, status):
|
|
696
|
+
try:
|
|
697
|
+
sys.stdout.write(format_result(job_dir, status))
|
|
698
|
+
sys.stdout.flush()
|
|
699
|
+
except OSError:
|
|
700
|
+
# The caller's stdout may be gone when a signal stopped the run.
|
|
701
|
+
pass
|
|
702
|
+
return EXIT_OK if status == "done" else EXIT_FAIL
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def append_progress(job_dir, message):
|
|
706
|
+
line = "{} {}\n".format(time.strftime("%H:%M:%S"), message)
|
|
707
|
+
try:
|
|
708
|
+
with open(job_path(job_dir, "progress.log"), "a", encoding="utf-8") as fh:
|
|
709
|
+
fh.write(line)
|
|
710
|
+
except OSError:
|
|
711
|
+
pass
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def record_end(job_dir, status, output=None, detail=None):
|
|
715
|
+
"""Record the end of the job's current run; output None keeps output.txt.
|
|
716
|
+
|
|
717
|
+
exit_code is written last: once it exists, readers trust the status
|
|
718
|
+
file and the rest of the run's state.
|
|
719
|
+
"""
|
|
720
|
+
if output is not None:
|
|
721
|
+
write_atomic(job_path(job_dir, "output.txt"), output + ("\n" if output else ""))
|
|
722
|
+
write_atomic(job_path(job_dir, "ended_at"), utcnow() + "\n")
|
|
723
|
+
write_atomic(job_path(job_dir, "status"), status + "\n")
|
|
724
|
+
write_atomic(
|
|
725
|
+
job_path(job_dir, "exit_code"),
|
|
726
|
+
f"{EXIT_OK if status == 'done' else EXIT_FAIL}\n",
|
|
727
|
+
)
|
|
728
|
+
message = "run {} ended: {}".format(read_int(job_path(job_dir, "runs")), status)
|
|
729
|
+
append_progress(job_dir, message + (f" ({detail})" if detail else ""))
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# --- runner ------------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def child_env(job_dir, job):
|
|
736
|
+
env = dict(os.environ)
|
|
737
|
+
for key in REMOVED_ENV:
|
|
738
|
+
env.pop(key, None)
|
|
739
|
+
env["PI_MULTI_AGENT_JOB"] = job_dir
|
|
740
|
+
env["PI_MULTI_AGENT_DEPTH"] = str(job["depth"])
|
|
741
|
+
env["PI_MINIMAL_PERMISSION_SYSTEM_NONINTERACTIVE"] = "1"
|
|
742
|
+
env["PI_REQUEST_USER_INPUT_NONINTERACTIVE"] = "1"
|
|
743
|
+
env["PWD"] = job["cwd"]
|
|
744
|
+
return env
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def pi_argv(job_dir, job):
|
|
748
|
+
argv = [pi_bin(), "--mode", "rpc", "--session-dir", job["session_dir"]]
|
|
749
|
+
if job.get("fork") and job.get("run_kind") == "new":
|
|
750
|
+
argv += ["--fork", job["fork"]]
|
|
751
|
+
argv += ["--session-id", job["name"]]
|
|
752
|
+
argv += job["pi_args"]
|
|
753
|
+
argv += ["--append-system-prompt", job_path(job_dir, "append-prompt.md")]
|
|
754
|
+
return argv
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def short_args(args):
|
|
758
|
+
if isinstance(args, dict):
|
|
759
|
+
for key in ("command", "path", "file_path", "pattern", "query", "url"):
|
|
760
|
+
value = args.get(key)
|
|
761
|
+
if isinstance(value, str) and value:
|
|
762
|
+
return excerpt(value)
|
|
763
|
+
return excerpt(json.dumps(args, ensure_ascii=False, separators=(",", ":")))
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
class Runner:
|
|
767
|
+
"""Owns one pi RPC process for one run of a job.
|
|
768
|
+
|
|
769
|
+
lock_fd holds the job's lock, which the starting command handed over and
|
|
770
|
+
the runner keeps until it exits. server is the job's listening control
|
|
771
|
+
socket. caller_fd is the read end of a pipe whose only write end the
|
|
772
|
+
foreground `run` process holds. End of file on it means that process has
|
|
773
|
+
ended, for any reason, SIGKILL included, and the run stops.
|
|
774
|
+
"""
|
|
775
|
+
|
|
776
|
+
def __init__(self, job_dir, lock_fd, server, caller_fd=None):
|
|
777
|
+
self.job_dir = job_dir
|
|
778
|
+
self.job = read_json(job_path(job_dir, "job.json"))
|
|
779
|
+
self.prompt = read_text(job_path(job_dir, "prompt.txt"), "")
|
|
780
|
+
self.lock_fd = lock_fd
|
|
781
|
+
self.server = server
|
|
782
|
+
# Accepted connections whose request line is not complete yet.
|
|
783
|
+
self.requests = {}
|
|
784
|
+
self.caller_fd = caller_fd
|
|
785
|
+
self.caller_gone = False
|
|
786
|
+
self.proc = None
|
|
787
|
+
self.out_fd = None
|
|
788
|
+
self.buffer = b""
|
|
789
|
+
self.eof = False
|
|
790
|
+
self.stdin_open = False
|
|
791
|
+
self.selector = selectors.DefaultSelector()
|
|
792
|
+
self.wake_r = None
|
|
793
|
+
self.signum = None
|
|
794
|
+
self.serial = 0
|
|
795
|
+
self.prompt_response = None
|
|
796
|
+
# RPC id of a forwarded steer -> the connection waiting for pi's answer.
|
|
797
|
+
self.pending_acks = {}
|
|
798
|
+
self.kill_requested = False
|
|
799
|
+
self.settled = False
|
|
800
|
+
self.last_stop = None
|
|
801
|
+
self.last_error = None
|
|
802
|
+
# Text of this run's latest assistant message. pi's
|
|
803
|
+
# get_last_assistant_text would also return an earlier run's text,
|
|
804
|
+
# or the caller's after --fork.
|
|
805
|
+
self.last_text = None
|
|
806
|
+
self.model_used = None
|
|
807
|
+
self.usage = read_json(job_path(job_dir, "usage.json"), None) or {}
|
|
808
|
+
self.abort_sent = False
|
|
809
|
+
self.abort_requested = False
|
|
810
|
+
self.timing_out = False
|
|
811
|
+
self.stop_deadline = None
|
|
812
|
+
self.finishing = False
|
|
813
|
+
|
|
814
|
+
# -- process and pipes --
|
|
815
|
+
|
|
816
|
+
def install_signals(self):
|
|
817
|
+
wake_r, wake_w = os.pipe()
|
|
818
|
+
os.set_blocking(wake_r, False)
|
|
819
|
+
os.set_blocking(wake_w, False)
|
|
820
|
+
self.wake_r = wake_r
|
|
821
|
+
signal.set_wakeup_fd(wake_w, warn_on_full_buffer=False)
|
|
822
|
+
self.selector.register(wake_r, selectors.EVENT_READ, "wake")
|
|
823
|
+
|
|
824
|
+
def on_signal(signum, _frame):
|
|
825
|
+
if self.signum is None:
|
|
826
|
+
self.signum = signum
|
|
827
|
+
|
|
828
|
+
for signum in STOP_SIGNALS:
|
|
829
|
+
# An ignored signal stays ignored, so `nohup` keeps working.
|
|
830
|
+
if signal.getsignal(signum) is not signal.SIG_IGN:
|
|
831
|
+
signal.signal(signum, on_signal)
|
|
832
|
+
|
|
833
|
+
def watch_caller(self):
|
|
834
|
+
if self.caller_fd is None:
|
|
835
|
+
return
|
|
836
|
+
os.set_inheritable(self.caller_fd, False)
|
|
837
|
+
os.set_blocking(self.caller_fd, False)
|
|
838
|
+
self.selector.register(self.caller_fd, selectors.EVENT_READ, "caller")
|
|
839
|
+
|
|
840
|
+
def listen(self):
|
|
841
|
+
# Neither fd may reach pi: a pi that closed the lock fd would make
|
|
842
|
+
# the job look gone while it runs.
|
|
843
|
+
os.set_inheritable(self.lock_fd, False)
|
|
844
|
+
self.server.set_inheritable(False)
|
|
845
|
+
self.server.setblocking(False)
|
|
846
|
+
self.selector.register(self.server, selectors.EVENT_READ, "server")
|
|
847
|
+
|
|
848
|
+
def read_caller(self):
|
|
849
|
+
try:
|
|
850
|
+
data = os.read(self.caller_fd, 512)
|
|
851
|
+
except BlockingIOError:
|
|
852
|
+
return
|
|
853
|
+
except OSError:
|
|
854
|
+
data = b""
|
|
855
|
+
if not data:
|
|
856
|
+
self.caller_gone = True
|
|
857
|
+
self.selector.unregister(self.caller_fd)
|
|
858
|
+
os.close(self.caller_fd)
|
|
859
|
+
self.caller_fd = None
|
|
860
|
+
|
|
861
|
+
def start(self):
|
|
862
|
+
with open(job_path(self.job_dir, "stderr.txt"), "wb") as stderr_fh:
|
|
863
|
+
self.proc = subprocess.Popen(
|
|
864
|
+
pi_argv(self.job_dir, self.job),
|
|
865
|
+
cwd=self.job["cwd"],
|
|
866
|
+
env=child_env(self.job_dir, self.job),
|
|
867
|
+
stdin=subprocess.PIPE,
|
|
868
|
+
stdout=subprocess.PIPE,
|
|
869
|
+
stderr=stderr_fh,
|
|
870
|
+
# pi gets its own session, so signals for the runner's process
|
|
871
|
+
# group never reach it; the runner stops it through its stdin.
|
|
872
|
+
start_new_session=True,
|
|
873
|
+
)
|
|
874
|
+
self.stdin_open = True
|
|
875
|
+
self.out_fd = self.proc.stdout.fileno()
|
|
876
|
+
self.selector.register(self.out_fd, selectors.EVENT_READ, "pi")
|
|
877
|
+
|
|
878
|
+
def send(self, obj):
|
|
879
|
+
if not self.stdin_open:
|
|
880
|
+
return False
|
|
881
|
+
try:
|
|
882
|
+
self.proc.stdin.write(json.dumps(obj).encode("utf-8") + b"\n")
|
|
883
|
+
self.proc.stdin.flush()
|
|
884
|
+
return True
|
|
885
|
+
except OSError:
|
|
886
|
+
self.stdin_open = False
|
|
887
|
+
return False
|
|
888
|
+
|
|
889
|
+
def close_stdin(self):
|
|
890
|
+
if self.proc is not None and self.proc.stdin is not None:
|
|
891
|
+
try:
|
|
892
|
+
self.proc.stdin.close()
|
|
893
|
+
except OSError:
|
|
894
|
+
pass
|
|
895
|
+
self.stdin_open = False
|
|
896
|
+
|
|
897
|
+
def new_id(self, kind):
|
|
898
|
+
self.serial += 1
|
|
899
|
+
return f"{kind}-{self.serial}"
|
|
900
|
+
|
|
901
|
+
def pump(self, timeout):
|
|
902
|
+
"""Wait up to timeout seconds (None: no limit) and handle what arrives.
|
|
903
|
+
|
|
904
|
+
That is pi's records, control requests, signals and the end of the
|
|
905
|
+
foreground command.
|
|
906
|
+
"""
|
|
907
|
+
for key, _events in self.selector.select(
|
|
908
|
+
None if timeout is None else max(0.0, timeout)
|
|
909
|
+
):
|
|
910
|
+
if key.data == "wake":
|
|
911
|
+
try:
|
|
912
|
+
while os.read(self.wake_r, 512):
|
|
913
|
+
pass
|
|
914
|
+
except OSError:
|
|
915
|
+
pass
|
|
916
|
+
elif key.data == "caller":
|
|
917
|
+
self.read_caller()
|
|
918
|
+
elif key.data == "server":
|
|
919
|
+
self.accept()
|
|
920
|
+
elif key.data == "request":
|
|
921
|
+
self.read_request(key.fileobj)
|
|
922
|
+
else:
|
|
923
|
+
self.read_pi()
|
|
924
|
+
|
|
925
|
+
def read_pi(self):
|
|
926
|
+
try:
|
|
927
|
+
data = os.read(self.out_fd, 65536)
|
|
928
|
+
except OSError:
|
|
929
|
+
data = b""
|
|
930
|
+
if not data:
|
|
931
|
+
self.eof = True
|
|
932
|
+
self.selector.unregister(self.out_fd)
|
|
933
|
+
if self.buffer:
|
|
934
|
+
self.handle_line(self.buffer)
|
|
935
|
+
self.buffer = b""
|
|
936
|
+
return
|
|
937
|
+
self.buffer += data
|
|
938
|
+
# Records end at LF only. U+2028 and U+2029 are valid inside JSON strings.
|
|
939
|
+
while b"\n" in self.buffer:
|
|
940
|
+
line, self.buffer = self.buffer.split(b"\n", 1)
|
|
941
|
+
self.handle_line(line)
|
|
942
|
+
|
|
943
|
+
def handle_line(self, line):
|
|
944
|
+
line = line.rstrip(b"\r")
|
|
945
|
+
if not line.strip():
|
|
946
|
+
return
|
|
947
|
+
try:
|
|
948
|
+
record = json.loads(line.decode("utf-8", "replace"))
|
|
949
|
+
except ValueError:
|
|
950
|
+
return
|
|
951
|
+
if isinstance(record, dict):
|
|
952
|
+
self.handle(record)
|
|
953
|
+
|
|
954
|
+
def handle(self, record):
|
|
955
|
+
kind = record.get("type")
|
|
956
|
+
if kind == "response":
|
|
957
|
+
rid = record.get("id")
|
|
958
|
+
if rid == "prompt":
|
|
959
|
+
self.prompt_response = record
|
|
960
|
+
elif rid in self.pending_acks:
|
|
961
|
+
conn = self.pending_acks.pop(rid)
|
|
962
|
+
if record.get("success"):
|
|
963
|
+
disposition = (record.get("data") or {}).get(
|
|
964
|
+
"disposition"
|
|
965
|
+
) or "queued"
|
|
966
|
+
self.reply(conn, {"ok": True, "disposition": disposition})
|
|
967
|
+
else:
|
|
968
|
+
self.reply(
|
|
969
|
+
conn,
|
|
970
|
+
{"ok": False, "error": record.get("error") or "steer failed"},
|
|
971
|
+
)
|
|
972
|
+
elif kind == "tool_execution_start":
|
|
973
|
+
self.progress(
|
|
974
|
+
"{} {}".format(
|
|
975
|
+
record.get("toolName", "?"), short_args(record.get("args"))
|
|
976
|
+
)
|
|
977
|
+
)
|
|
978
|
+
elif kind == "message_end":
|
|
979
|
+
message = record.get("message") or {}
|
|
980
|
+
if message.get("role") == "assistant":
|
|
981
|
+
self.on_assistant_message(message)
|
|
982
|
+
elif kind == "auto_retry_start":
|
|
983
|
+
self.progress(
|
|
984
|
+
"retry {}/{}: {}".format(
|
|
985
|
+
record.get("attempt"),
|
|
986
|
+
record.get("maxAttempts"),
|
|
987
|
+
excerpt(record.get("errorMessage") or ""),
|
|
988
|
+
)
|
|
989
|
+
)
|
|
990
|
+
elif kind == "compaction_start":
|
|
991
|
+
self.progress("compaction ({})".format(record.get("reason", "?")))
|
|
992
|
+
elif kind == "extension_ui_request" and record.get("method") in DIALOG_METHODS:
|
|
993
|
+
# No person can answer. Without a reply the extension would wait, often forever.
|
|
994
|
+
self.send(
|
|
995
|
+
{
|
|
996
|
+
"type": "extension_ui_response",
|
|
997
|
+
"id": record.get("id"),
|
|
998
|
+
"cancelled": True,
|
|
999
|
+
}
|
|
1000
|
+
)
|
|
1001
|
+
self.progress(
|
|
1002
|
+
"denied: {}".format(
|
|
1003
|
+
excerpt(record.get("title") or record.get("method"))
|
|
1004
|
+
)
|
|
1005
|
+
)
|
|
1006
|
+
elif kind == "agent_settled":
|
|
1007
|
+
self.settled = True
|
|
1008
|
+
|
|
1009
|
+
def on_assistant_message(self, message):
|
|
1010
|
+
self.last_stop = message.get("stopReason")
|
|
1011
|
+
self.last_error = message.get("errorMessage")
|
|
1012
|
+
content = message.get("content") or []
|
|
1013
|
+
# As in pi's get_last_assistant_text, an aborted message without
|
|
1014
|
+
# content keeps the text before it.
|
|
1015
|
+
if content or self.last_stop != "aborted":
|
|
1016
|
+
parts = (
|
|
1017
|
+
content
|
|
1018
|
+
if isinstance(content, list)
|
|
1019
|
+
else [{"type": "text", "text": str(content)}]
|
|
1020
|
+
)
|
|
1021
|
+
text = "".join(
|
|
1022
|
+
p.get("text") or ""
|
|
1023
|
+
for p in parts
|
|
1024
|
+
if isinstance(p, dict) and p.get("type") == "text"
|
|
1025
|
+
)
|
|
1026
|
+
self.last_text = text.strip() or None
|
|
1027
|
+
if message.get("provider") and message.get("model"):
|
|
1028
|
+
self.model_used = "{}/{}".format(message["provider"], message["model"])
|
|
1029
|
+
elif message.get("model"):
|
|
1030
|
+
self.model_used = message["model"]
|
|
1031
|
+
usage = message.get("usage") or {}
|
|
1032
|
+
for key in ("input", "output", "cacheRead", "cacheWrite", "totalTokens"):
|
|
1033
|
+
if isinstance(usage.get(key), (int, float)):
|
|
1034
|
+
self.usage[key] = self.usage.get(key, 0) + usage[key]
|
|
1035
|
+
cost = (usage.get("cost") or {}).get("total")
|
|
1036
|
+
if isinstance(cost, (int, float)):
|
|
1037
|
+
self.usage["cost"] = round(self.usage.get("cost", 0) + cost, 6)
|
|
1038
|
+
|
|
1039
|
+
def progress(self, message):
|
|
1040
|
+
append_progress(self.job_dir, message)
|
|
1041
|
+
|
|
1042
|
+
# -- control socket --
|
|
1043
|
+
|
|
1044
|
+
def accept(self):
|
|
1045
|
+
while True:
|
|
1046
|
+
try:
|
|
1047
|
+
conn, _addr = self.server.accept()
|
|
1048
|
+
except OSError:
|
|
1049
|
+
# BlockingIOError: no more connections are waiting.
|
|
1050
|
+
return
|
|
1051
|
+
conn.setblocking(False)
|
|
1052
|
+
self.requests[conn] = b""
|
|
1053
|
+
self.selector.register(conn, selectors.EVENT_READ, "request")
|
|
1054
|
+
|
|
1055
|
+
def read_request(self, conn):
|
|
1056
|
+
"""Read from a connection until its request line is complete, then handle it."""
|
|
1057
|
+
try:
|
|
1058
|
+
data = conn.recv(65536)
|
|
1059
|
+
except BlockingIOError:
|
|
1060
|
+
return
|
|
1061
|
+
except OSError:
|
|
1062
|
+
data = b""
|
|
1063
|
+
if data:
|
|
1064
|
+
self.requests[conn] += data
|
|
1065
|
+
if b"\n" not in self.requests[conn]:
|
|
1066
|
+
return
|
|
1067
|
+
self.selector.unregister(conn)
|
|
1068
|
+
line = self.requests.pop(conn).split(b"\n", 1)[0]
|
|
1069
|
+
if not data and not line:
|
|
1070
|
+
conn.close()
|
|
1071
|
+
return
|
|
1072
|
+
try:
|
|
1073
|
+
request = json.loads(line.decode("utf-8", "replace"))
|
|
1074
|
+
except ValueError:
|
|
1075
|
+
request = None
|
|
1076
|
+
self.handle_request(conn, request)
|
|
1077
|
+
|
|
1078
|
+
def handle_request(self, conn, request):
|
|
1079
|
+
kind = request.get("type") if isinstance(request, dict) else None
|
|
1080
|
+
if self.finishing or self.settled:
|
|
1081
|
+
self.reply(conn, {"ok": False, "error": NOT_RUNNING})
|
|
1082
|
+
elif kind == "steer":
|
|
1083
|
+
message = str(request.get("message", ""))
|
|
1084
|
+
rid = self.new_id("steer")
|
|
1085
|
+
if self.send({"id": rid, "type": "steer", "message": message}):
|
|
1086
|
+
# Answered when pi answers the steer.
|
|
1087
|
+
self.pending_acks[rid] = conn
|
|
1088
|
+
self.progress(f"steer: {excerpt(message)}")
|
|
1089
|
+
else:
|
|
1090
|
+
self.reply(conn, {"ok": False, "error": NOT_RUNNING})
|
|
1091
|
+
elif kind == "abort":
|
|
1092
|
+
self.abort_requested = True
|
|
1093
|
+
self.progress("abort requested")
|
|
1094
|
+
self.send_abort()
|
|
1095
|
+
# Answered at once: pi answers RPC abort only after the agent has
|
|
1096
|
+
# settled, when the run is already ending.
|
|
1097
|
+
self.reply(conn, {"ok": True, "disposition": "requested"})
|
|
1098
|
+
elif kind == "kill":
|
|
1099
|
+
# The run loop stops the run as it does for a signal.
|
|
1100
|
+
self.kill_requested = True
|
|
1101
|
+
self.reply(conn, {"ok": True, "disposition": "stopping"})
|
|
1102
|
+
else:
|
|
1103
|
+
self.reply(conn, {"ok": False, "error": f"unknown request type: {kind}"})
|
|
1104
|
+
|
|
1105
|
+
def reply(self, conn, obj):
|
|
1106
|
+
try:
|
|
1107
|
+
conn.settimeout(1)
|
|
1108
|
+
conn.sendall(json.dumps(obj).encode("utf-8") + b"\n")
|
|
1109
|
+
except OSError:
|
|
1110
|
+
# The command gave up waiting.
|
|
1111
|
+
pass
|
|
1112
|
+
conn.close()
|
|
1113
|
+
|
|
1114
|
+
def close_control(self):
|
|
1115
|
+
"""Answer every request still open with NOT_RUNNING, then remove the socket.
|
|
1116
|
+
|
|
1117
|
+
Called once the run's end is recorded. The runner still holds the
|
|
1118
|
+
job's lock, so no new run has bound the socket path, and removing it
|
|
1119
|
+
is safe.
|
|
1120
|
+
"""
|
|
1121
|
+
for fileobj in [self.server] + list(self.requests):
|
|
1122
|
+
try:
|
|
1123
|
+
self.selector.unregister(fileobj)
|
|
1124
|
+
except (KeyError, ValueError):
|
|
1125
|
+
pass
|
|
1126
|
+
unread = list(self.requests.items())
|
|
1127
|
+
while True:
|
|
1128
|
+
try:
|
|
1129
|
+
conn, _addr = self.server.accept()
|
|
1130
|
+
except OSError:
|
|
1131
|
+
break
|
|
1132
|
+
unread.append((conn, b""))
|
|
1133
|
+
for conn, data in unread:
|
|
1134
|
+
# Read the request first: closing a socket with unread data makes
|
|
1135
|
+
# the command's read fail with ECONNRESET instead of the answer.
|
|
1136
|
+
try:
|
|
1137
|
+
conn.settimeout(0.5)
|
|
1138
|
+
while b"\n" not in data:
|
|
1139
|
+
chunk = conn.recv(65536)
|
|
1140
|
+
if not chunk:
|
|
1141
|
+
break
|
|
1142
|
+
data += chunk
|
|
1143
|
+
except OSError:
|
|
1144
|
+
pass
|
|
1145
|
+
for conn in list(self.pending_acks.values()) + [conn for conn, _data in unread]:
|
|
1146
|
+
self.reply(conn, {"ok": False, "error": NOT_RUNNING})
|
|
1147
|
+
self.pending_acks = {}
|
|
1148
|
+
self.requests = {}
|
|
1149
|
+
remove(socket_path(self.job_dir))
|
|
1150
|
+
self.server.close()
|
|
1151
|
+
|
|
1152
|
+
def send_abort(self):
|
|
1153
|
+
if self.abort_sent:
|
|
1154
|
+
return
|
|
1155
|
+
self.abort_sent = True
|
|
1156
|
+
# pi continues with queued messages after an abort unless the queue is cleared.
|
|
1157
|
+
self.send({"id": self.new_id("clear_queue"), "type": "clear_queue"})
|
|
1158
|
+
self.send({"id": self.new_id("abort"), "type": "abort"})
|
|
1159
|
+
self.stop_deadline = time.monotonic() + SETTLE_TIMEOUT_S
|
|
1160
|
+
|
|
1161
|
+
# -- the run --
|
|
1162
|
+
|
|
1163
|
+
def run(self):
|
|
1164
|
+
self.install_signals()
|
|
1165
|
+
self.watch_caller()
|
|
1166
|
+
self.listen()
|
|
1167
|
+
try:
|
|
1168
|
+
self.start()
|
|
1169
|
+
except OSError as exc:
|
|
1170
|
+
return self.conclude("failed", f"could not start {pi_bin()}: {exc}")
|
|
1171
|
+
self.progress(
|
|
1172
|
+
"run {} started ({})".format(
|
|
1173
|
+
read_int(job_path(self.job_dir, "runs")), self.job["mode"]
|
|
1174
|
+
)
|
|
1175
|
+
)
|
|
1176
|
+
self.send({"id": "prompt", "type": "prompt", "message": self.prompt})
|
|
1177
|
+
|
|
1178
|
+
timeout = self.job.get("timeout")
|
|
1179
|
+
deadline = time.monotonic() + timeout if timeout else None
|
|
1180
|
+
while True:
|
|
1181
|
+
if self.signum is not None:
|
|
1182
|
+
return self.finish_killed(
|
|
1183
|
+
f"stopped by {signal.Signals(self.signum).name}"
|
|
1184
|
+
)
|
|
1185
|
+
if self.caller_gone:
|
|
1186
|
+
return self.finish_killed("stopped: the caller's command ended")
|
|
1187
|
+
if self.kill_requested:
|
|
1188
|
+
return self.finish_killed("stopped by kill")
|
|
1189
|
+
now = time.monotonic()
|
|
1190
|
+
if deadline is not None and now >= deadline and not self.timing_out:
|
|
1191
|
+
self.timing_out = True
|
|
1192
|
+
self.progress(f"timeout after {format_seconds(timeout)}s; aborting")
|
|
1193
|
+
self.send_abort()
|
|
1194
|
+
if self.stop_deadline is not None and now >= self.stop_deadline:
|
|
1195
|
+
self.progress("agent did not settle after the abort")
|
|
1196
|
+
return self.finish("timeout" if self.timing_out else "aborted")
|
|
1197
|
+
if self.settled:
|
|
1198
|
+
return self.finish(self.settled_status())
|
|
1199
|
+
response = self.prompt_response
|
|
1200
|
+
if response is not None and not response.get("success"):
|
|
1201
|
+
return self.finish("failed", response.get("error") or "prompt rejected")
|
|
1202
|
+
if (
|
|
1203
|
+
response is not None
|
|
1204
|
+
and (response.get("data") or {}).get("disposition") == "handled"
|
|
1205
|
+
):
|
|
1206
|
+
return self.finish("done")
|
|
1207
|
+
if self.eof:
|
|
1208
|
+
return self.finish_eof()
|
|
1209
|
+
# Every event wakes the selector, so it waits for the next
|
|
1210
|
+
# deadline only. Once the timeout has fired, only the settle
|
|
1211
|
+
# deadline is ahead.
|
|
1212
|
+
wait = None
|
|
1213
|
+
for limit in (None if self.timing_out else deadline, self.stop_deadline):
|
|
1214
|
+
if limit is not None:
|
|
1215
|
+
wait = limit - now if wait is None else min(wait, limit - now)
|
|
1216
|
+
self.pump(wait)
|
|
1217
|
+
|
|
1218
|
+
def settled_status(self):
|
|
1219
|
+
if self.timing_out:
|
|
1220
|
+
return "timeout"
|
|
1221
|
+
if self.abort_requested or self.last_stop == "aborted":
|
|
1222
|
+
return "aborted"
|
|
1223
|
+
if self.last_stop == "error":
|
|
1224
|
+
return "failed"
|
|
1225
|
+
return "done"
|
|
1226
|
+
|
|
1227
|
+
def finish(self, status, error=None):
|
|
1228
|
+
self.finishing = True
|
|
1229
|
+
self.stop_pi()
|
|
1230
|
+
output = self.last_text or ""
|
|
1231
|
+
if status == "failed":
|
|
1232
|
+
detail = error or self.last_error
|
|
1233
|
+
if detail:
|
|
1234
|
+
output = (output + "\n\n" if output else "") + "error: " + detail
|
|
1235
|
+
return self.conclude(status, output)
|
|
1236
|
+
|
|
1237
|
+
def finish_eof(self):
|
|
1238
|
+
self.finishing = True
|
|
1239
|
+
self.stop_pi()
|
|
1240
|
+
stderr = read_text(job_path(self.job_dir, "stderr.txt"), "") or ""
|
|
1241
|
+
tail = "\n".join(stderr.rstrip("\n").splitlines()[-20:])
|
|
1242
|
+
output = (
|
|
1243
|
+
f"pi exited before the run finished (exit status {self.proc.returncode})."
|
|
1244
|
+
)
|
|
1245
|
+
if tail:
|
|
1246
|
+
output += " Last lines of its stderr:\n\n" + tail
|
|
1247
|
+
return self.conclude("failed", output)
|
|
1248
|
+
|
|
1249
|
+
def finish_killed(self, reason):
|
|
1250
|
+
self.finishing = True
|
|
1251
|
+
self.progress(reason)
|
|
1252
|
+
self.stop_pi()
|
|
1253
|
+
return self.conclude("killed", self.last_text or "")
|
|
1254
|
+
|
|
1255
|
+
def stop_pi(self):
|
|
1256
|
+
"""Close pi's stdin, which makes it shut down; escalate to SIGTERM and SIGKILL."""
|
|
1257
|
+
self.close_stdin()
|
|
1258
|
+
if self.proc is None:
|
|
1259
|
+
return
|
|
1260
|
+
for signum, grace in (
|
|
1261
|
+
(None, PI_EXIT_GRACE_S),
|
|
1262
|
+
(signal.SIGTERM, PI_TERM_GRACE_S),
|
|
1263
|
+
(signal.SIGKILL, 0),
|
|
1264
|
+
):
|
|
1265
|
+
if self.proc.poll() is not None:
|
|
1266
|
+
break
|
|
1267
|
+
if signum is not None:
|
|
1268
|
+
try:
|
|
1269
|
+
self.proc.send_signal(signum)
|
|
1270
|
+
except OSError:
|
|
1271
|
+
pass
|
|
1272
|
+
end = time.monotonic() + grace
|
|
1273
|
+
while self.proc.poll() is None and time.monotonic() < end:
|
|
1274
|
+
# Keep reading: pi stalls if its stdout pipe fills during
|
|
1275
|
+
# shutdown. Requests meanwhile get NOT_RUNNING.
|
|
1276
|
+
self.pump(0.1)
|
|
1277
|
+
self.proc.wait()
|
|
1278
|
+
|
|
1279
|
+
def conclude(self, status, output):
|
|
1280
|
+
self.finishing = True
|
|
1281
|
+
write_atomic(
|
|
1282
|
+
job_path(self.job_dir, "usage.json"),
|
|
1283
|
+
json.dumps(self.usage, indent=2) + "\n",
|
|
1284
|
+
)
|
|
1285
|
+
if self.model_used:
|
|
1286
|
+
write_atomic(job_path(self.job_dir, "model"), self.model_used + "\n")
|
|
1287
|
+
record_end(self.job_dir, status, output)
|
|
1288
|
+
return status
|
|
1289
|
+
|
|
1290
|
+
def emergency_stop(self):
|
|
1291
|
+
"""Best-effort cleanup after an unexpected error in the runner."""
|
|
1292
|
+
self.close_stdin()
|
|
1293
|
+
if self.proc is not None and self.proc.poll() is None:
|
|
1294
|
+
try:
|
|
1295
|
+
self.proc.wait(PI_EXIT_GRACE_S)
|
|
1296
|
+
except subprocess.TimeoutExpired:
|
|
1297
|
+
self.proc.kill()
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def format_seconds(value):
|
|
1301
|
+
return (f"{value:g}") if value is not None else "none"
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def run_runner(job_dir, lock_fd, server, caller_fd=None):
|
|
1305
|
+
"""Run one job run to completion and return its final status.
|
|
1306
|
+
|
|
1307
|
+
The job's lock is released when this process exits.
|
|
1308
|
+
"""
|
|
1309
|
+
runner = Runner(job_dir, lock_fd, server, caller_fd)
|
|
1310
|
+
try:
|
|
1311
|
+
return runner.run()
|
|
1312
|
+
except Exception: # noqa: BLE001
|
|
1313
|
+
# Record unexpected failures and stop pi before releasing the job's lock.
|
|
1314
|
+
with open(job_path(job_dir, "runner.log"), "a", encoding="utf-8") as fh:
|
|
1315
|
+
fh.write(f"{utcnow()}\n{traceback.format_exc()}\n")
|
|
1316
|
+
runner.emergency_stop()
|
|
1317
|
+
return runner.conclude(
|
|
1318
|
+
"failed",
|
|
1319
|
+
"the runner failed; see {}".format(job_path(job_dir, "runner.log")),
|
|
1320
|
+
)
|
|
1321
|
+
finally:
|
|
1322
|
+
runner.close_control()
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
# --- starting runs -----------------------------------------------------------
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def caller_depth():
|
|
1329
|
+
try:
|
|
1330
|
+
return int(os.environ.get("PI_MULTI_AGENT_DEPTH") or 0)
|
|
1331
|
+
except ValueError:
|
|
1332
|
+
return 0
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def check_depth():
|
|
1336
|
+
depth = caller_depth()
|
|
1337
|
+
try:
|
|
1338
|
+
limit = int(os.environ.get("PI_MULTI_AGENT_MAX_DEPTH") or DEFAULT_MAX_DEPTH)
|
|
1339
|
+
except ValueError:
|
|
1340
|
+
limit = DEFAULT_MAX_DEPTH
|
|
1341
|
+
if depth >= limit:
|
|
1342
|
+
raise CommandError(
|
|
1343
|
+
f"cannot start an agent: this caller is at depth {depth} and PI_MULTI_AGENT_MAX_DEPTH is {limit}"
|
|
1344
|
+
)
|
|
1345
|
+
return depth + 1
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
PREVIOUS_RUN_FILES = ("exit_code", "ended_at", "output.txt", "model")
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def start_run(job_dir, lock_fd, server, job, prompt, background, timeout, run_kind):
|
|
1352
|
+
"""Record a new run of the job and start its runner.
|
|
1353
|
+
|
|
1354
|
+
lock_fd holds the job's lock and server is its listening control socket.
|
|
1355
|
+
Both pass to the runner, and this process closes its copies, so the
|
|
1356
|
+
runner holds the lock from here on with no gap.
|
|
1357
|
+
|
|
1358
|
+
The runner is always a detached process. In the foreground this process
|
|
1359
|
+
stays as the runner's caller: it waits for the runner, prints the result,
|
|
1360
|
+
and holds the write end of the pipe whose end of file stops the run.
|
|
1361
|
+
"""
|
|
1362
|
+
try:
|
|
1363
|
+
proc, caller_w = start_runner(
|
|
1364
|
+
job_dir, lock_fd, server, job, prompt, background, timeout, run_kind
|
|
1365
|
+
)
|
|
1366
|
+
finally:
|
|
1367
|
+
server.close()
|
|
1368
|
+
os.close(lock_fd)
|
|
1369
|
+
if background:
|
|
1370
|
+
sys.stdout.write(
|
|
1371
|
+
"job: {}\nstatus: running\ntimeout_s: {}\n".format(
|
|
1372
|
+
job["name"], format_seconds(timeout)
|
|
1373
|
+
)
|
|
1374
|
+
)
|
|
1375
|
+
return EXIT_OK
|
|
1376
|
+
note("started job {}".format(job["name"]))
|
|
1377
|
+
return follow_runner(job_dir, proc, caller_w)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def start_runner(job_dir, lock_fd, server, job, prompt, background, timeout, run_kind):
|
|
1381
|
+
"""Reset the job's state for a new run and start the runner; return it and the caller's pipe end.
|
|
1382
|
+
|
|
1383
|
+
Called with the job's lock held. If the runner cannot start, the run is
|
|
1384
|
+
recorded as failed.
|
|
1385
|
+
"""
|
|
1386
|
+
job["mode"] = "background" if background else "foreground"
|
|
1387
|
+
job["timeout"] = timeout
|
|
1388
|
+
job["run_kind"] = run_kind
|
|
1389
|
+
write_atomic(job_path(job_dir, "job.json"), json.dumps(job, indent=2) + "\n")
|
|
1390
|
+
write_atomic(job_path(job_dir, "prompt.txt"), prompt)
|
|
1391
|
+
for name in PREVIOUS_RUN_FILES:
|
|
1392
|
+
remove(job_path(job_dir, name))
|
|
1393
|
+
runs = (read_int(job_path(job_dir, "runs")) or 0) + 1
|
|
1394
|
+
write_atomic(job_path(job_dir, "runs"), f"{runs}\n")
|
|
1395
|
+
write_atomic(job_path(job_dir, "started_at"), utcnow() + "\n")
|
|
1396
|
+
write_atomic(job_path(job_dir, "status"), "running\n")
|
|
1397
|
+
|
|
1398
|
+
# The job directory follows `__runner` and a space, so a person can find
|
|
1399
|
+
# a stuck runner with `pkill -f` (see kill_job).
|
|
1400
|
+
argv = [
|
|
1401
|
+
sys.executable,
|
|
1402
|
+
SCRIPT_PATH,
|
|
1403
|
+
"__runner",
|
|
1404
|
+
job_dir,
|
|
1405
|
+
str(lock_fd),
|
|
1406
|
+
str(server.fileno()),
|
|
1407
|
+
]
|
|
1408
|
+
pass_fds = [lock_fd, server.fileno()]
|
|
1409
|
+
caller_r = caller_w = None
|
|
1410
|
+
if not background:
|
|
1411
|
+
# os.pipe() descriptors are not inherited, so only the runner gets
|
|
1412
|
+
# the read end and only this process holds the write end.
|
|
1413
|
+
caller_r, caller_w = os.pipe()
|
|
1414
|
+
argv.append(str(caller_r))
|
|
1415
|
+
pass_fds.append(caller_r)
|
|
1416
|
+
try:
|
|
1417
|
+
proc = subprocess.Popen(
|
|
1418
|
+
argv,
|
|
1419
|
+
cwd=job["cwd"],
|
|
1420
|
+
# Its own session, so the caller's process group and terminal never
|
|
1421
|
+
# signal it. The caller's end is the pipe above.
|
|
1422
|
+
start_new_session=True,
|
|
1423
|
+
# No handle on the caller's stdout or stderr, so a command that runs
|
|
1424
|
+
# this script never waits for the runner.
|
|
1425
|
+
stdin=subprocess.DEVNULL,
|
|
1426
|
+
stdout=subprocess.DEVNULL,
|
|
1427
|
+
stderr=subprocess.DEVNULL,
|
|
1428
|
+
pass_fds=pass_fds,
|
|
1429
|
+
)
|
|
1430
|
+
except OSError as exc:
|
|
1431
|
+
for fd in (caller_r, caller_w):
|
|
1432
|
+
if fd is not None:
|
|
1433
|
+
os.close(fd)
|
|
1434
|
+
message = f"could not start the runner: {exc}"
|
|
1435
|
+
record_end(job_dir, "failed", "error: " + message, message)
|
|
1436
|
+
raise CommandError(message)
|
|
1437
|
+
if caller_r is not None:
|
|
1438
|
+
os.close(caller_r)
|
|
1439
|
+
return proc, caller_w
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def follow_runner(job_dir, proc, caller_w):
|
|
1443
|
+
"""Foreground: wait for the runner to end the run and print the result.
|
|
1444
|
+
|
|
1445
|
+
SIGTERM, SIGHUP or SIGINT closes caller_w, so the runner stops the run and
|
|
1446
|
+
records it, and this process still prints the result. A second signal
|
|
1447
|
+
ends this process at once. Any other end of this process, SIGKILL
|
|
1448
|
+
included, also closes caller_w.
|
|
1449
|
+
"""
|
|
1450
|
+
closed = []
|
|
1451
|
+
|
|
1452
|
+
def on_signal(_signum, _frame):
|
|
1453
|
+
if not closed:
|
|
1454
|
+
closed.append(True)
|
|
1455
|
+
os.close(caller_w)
|
|
1456
|
+
for signum in STOP_SIGNALS:
|
|
1457
|
+
if signal.getsignal(signum) is not signal.SIG_IGN:
|
|
1458
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
1459
|
+
|
|
1460
|
+
for signum in STOP_SIGNALS:
|
|
1461
|
+
if signal.getsignal(signum) is not signal.SIG_IGN:
|
|
1462
|
+
signal.signal(signum, on_signal)
|
|
1463
|
+
proc.wait()
|
|
1464
|
+
return print_result(job_dir, job_state(job_dir))
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def cmd_runner(argv):
|
|
1468
|
+
"""Internal: the detached runner of a run.
|
|
1469
|
+
|
|
1470
|
+
Its arguments are the job directory, the fd that holds the job's lock,
|
|
1471
|
+
the fd of the listening control socket, and, for a foreground run, the
|
|
1472
|
+
read end of the caller's pipe.
|
|
1473
|
+
"""
|
|
1474
|
+
job_dir, lock_fd, server_fd = argv[0], int(argv[1]), int(argv[2])
|
|
1475
|
+
caller_fd = int(argv[3]) if len(argv) > 3 else None
|
|
1476
|
+
run_runner(job_dir, lock_fd, socket.socket(fileno=server_fd), caller_fd)
|
|
1477
|
+
return EXIT_OK
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
# --- argument parsing --------------------------------------------------------
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
class Args:
|
|
1484
|
+
"""A small parser for `--opt value`, `--opt=value`, flags and positionals."""
|
|
1485
|
+
|
|
1486
|
+
def __init__(self, argv, with_value, flags):
|
|
1487
|
+
self.values = {}
|
|
1488
|
+
self.flags = set()
|
|
1489
|
+
self.positional = []
|
|
1490
|
+
i = 0
|
|
1491
|
+
only_positional = False
|
|
1492
|
+
while i < len(argv):
|
|
1493
|
+
arg = argv[i]
|
|
1494
|
+
if only_positional or arg == "-" or not arg.startswith("-"):
|
|
1495
|
+
self.positional.append(arg)
|
|
1496
|
+
elif arg == "--":
|
|
1497
|
+
only_positional = True
|
|
1498
|
+
elif arg in ("-h", "--help"):
|
|
1499
|
+
self.flags.add("help")
|
|
1500
|
+
else:
|
|
1501
|
+
name, eq, value = arg.partition("=")
|
|
1502
|
+
if name in with_value:
|
|
1503
|
+
if not eq:
|
|
1504
|
+
if i + 1 >= len(argv):
|
|
1505
|
+
raise usage_error(f"{name} needs a value")
|
|
1506
|
+
i += 1
|
|
1507
|
+
value = argv[i]
|
|
1508
|
+
self.values[name] = value
|
|
1509
|
+
elif name in flags and not eq:
|
|
1510
|
+
self.flags.add(name)
|
|
1511
|
+
else:
|
|
1512
|
+
raise usage_error(f"unknown option: {arg}")
|
|
1513
|
+
i += 1
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def parse_seconds(value, option):
|
|
1517
|
+
try:
|
|
1518
|
+
seconds = float(value)
|
|
1519
|
+
except ValueError:
|
|
1520
|
+
raise usage_error(f"{option} needs a number of seconds, got: {value}")
|
|
1521
|
+
if seconds < 0 or math.isnan(seconds):
|
|
1522
|
+
raise usage_error(f"{option} needs a number of seconds, got: {value}")
|
|
1523
|
+
return seconds
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def run_timeout(args, background):
|
|
1527
|
+
if "--timeout" in args.values:
|
|
1528
|
+
seconds = parse_seconds(args.values["--timeout"], "--timeout")
|
|
1529
|
+
return seconds or None
|
|
1530
|
+
return BACKGROUND_TIMEOUT_S if background else None
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def read_message(args, what):
|
|
1534
|
+
if len(args.positional) != 1:
|
|
1535
|
+
raise usage_error(f"expected one {what} (a string, or - to read stdin)")
|
|
1536
|
+
text = args.positional[0]
|
|
1537
|
+
if text == "-":
|
|
1538
|
+
text = sys.stdin.read().rstrip("\n")
|
|
1539
|
+
if not text.strip():
|
|
1540
|
+
raise usage_error(f"empty {what}")
|
|
1541
|
+
return text
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def one_name(args, command):
|
|
1545
|
+
if len(args.positional) != 1:
|
|
1546
|
+
raise usage_error(f"{command} needs one job name")
|
|
1547
|
+
return args.positional[0]
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
# --- commands ----------------------------------------------------------------
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def cmd_run(argv):
|
|
1554
|
+
args = Args(
|
|
1555
|
+
argv,
|
|
1556
|
+
("--profile", "--name", "--timeout", "--model", "--thinking", "--cwd"),
|
|
1557
|
+
("--background", "--fork"),
|
|
1558
|
+
)
|
|
1559
|
+
if "help" in args.flags:
|
|
1560
|
+
sys.stdout.write(USAGE + "\n")
|
|
1561
|
+
return EXIT_OK
|
|
1562
|
+
background = "--background" in args.flags
|
|
1563
|
+
timeout = run_timeout(args, background)
|
|
1564
|
+
project = logical_cwd()
|
|
1565
|
+
cwd = project
|
|
1566
|
+
if "--cwd" in args.values:
|
|
1567
|
+
cwd = os.path.normpath(
|
|
1568
|
+
os.path.join(project, os.path.expanduser(args.values["--cwd"]))
|
|
1569
|
+
)
|
|
1570
|
+
if not os.path.isdir(cwd):
|
|
1571
|
+
raise usage_error(
|
|
1572
|
+
"--cwd is not a directory: {}".format(args.values["--cwd"])
|
|
1573
|
+
)
|
|
1574
|
+
prompt = read_message(args, "prompt")
|
|
1575
|
+
fork = None
|
|
1576
|
+
if "--fork" in args.flags:
|
|
1577
|
+
fork = os.environ.get("PI_SESSION_FILE")
|
|
1578
|
+
if not fork:
|
|
1579
|
+
raise CommandError(
|
|
1580
|
+
"--fork needs PI_SESSION_FILE, which is not set in this environment"
|
|
1581
|
+
)
|
|
1582
|
+
if not os.path.isfile(fork):
|
|
1583
|
+
raise CommandError(f"--fork: PI_SESSION_FILE does not exist: {fork}")
|
|
1584
|
+
depth = check_depth()
|
|
1585
|
+
check_pi_bin()
|
|
1586
|
+
profile = find_profile(cwd, args.values.get("--profile") or DEFAULT_PROFILE)
|
|
1587
|
+
|
|
1588
|
+
root = jobs_dir(project)
|
|
1589
|
+
# The socket path has the same length for every name in root.
|
|
1590
|
+
check_socket_path(os.path.join(root, "name"))
|
|
1591
|
+
os.makedirs(root, exist_ok=True)
|
|
1592
|
+
name = args.values.get("--name")
|
|
1593
|
+
if name is not None:
|
|
1594
|
+
validate_name(name)
|
|
1595
|
+
try:
|
|
1596
|
+
os.mkdir(os.path.join(root, name))
|
|
1597
|
+
except FileExistsError:
|
|
1598
|
+
job_dir = os.path.join(root, name)
|
|
1599
|
+
status = job_state(job_dir)
|
|
1600
|
+
since = job_since(job_dir, status)
|
|
1601
|
+
detail = status
|
|
1602
|
+
if since is not None:
|
|
1603
|
+
event = "started" if status == "running" else "ended"
|
|
1604
|
+
detail += f", {event} {format_age(time.time() - since)} ago"
|
|
1605
|
+
raise CommandError(
|
|
1606
|
+
f"job {name} already exists in this project ({detail}); pick another --name. "
|
|
1607
|
+
f"Run `resume {name}` or `rm {name}` only for a job you started or the user asked about."
|
|
1608
|
+
)
|
|
1609
|
+
else:
|
|
1610
|
+
for _attempt in range(100):
|
|
1611
|
+
name = default_name(profile["name"])
|
|
1612
|
+
try:
|
|
1613
|
+
os.mkdir(os.path.join(root, name))
|
|
1614
|
+
break
|
|
1615
|
+
except FileExistsError:
|
|
1616
|
+
continue
|
|
1617
|
+
else:
|
|
1618
|
+
raise CommandError("could not find a free job name; pass --name")
|
|
1619
|
+
job_dir = os.path.join(root, name)
|
|
1620
|
+
|
|
1621
|
+
lock_fd = None
|
|
1622
|
+
try:
|
|
1623
|
+
# Taken at once, so the job directory never exists without a lock.
|
|
1624
|
+
lock_fd = job_lock(job_dir, f"job {name} is busy; try again")
|
|
1625
|
+
model = resolve_model(args.values.get("--model"), profile)
|
|
1626
|
+
thinking = resolve_thinking(args.values.get("--thinking"), profile)
|
|
1627
|
+
pi_args = profile_args(
|
|
1628
|
+
profile, model, thinking, job_path(job_dir, "system-prompt.md")
|
|
1629
|
+
)
|
|
1630
|
+
write_atomic(
|
|
1631
|
+
job_path(job_dir, "append-prompt.md"), append_prompt(profile, name)
|
|
1632
|
+
)
|
|
1633
|
+
session_dir = sessions_dir(project)
|
|
1634
|
+
os.makedirs(session_dir, exist_ok=True)
|
|
1635
|
+
job = {
|
|
1636
|
+
"name": name,
|
|
1637
|
+
"profile": profile["name"],
|
|
1638
|
+
"profile_source": profile["source"],
|
|
1639
|
+
"cwd": cwd,
|
|
1640
|
+
"project": project,
|
|
1641
|
+
"session_dir": session_dir,
|
|
1642
|
+
"parent": os.environ.get("PI_MULTI_AGENT_JOB") or None,
|
|
1643
|
+
"depth": depth,
|
|
1644
|
+
"model": model,
|
|
1645
|
+
"thinking": thinking,
|
|
1646
|
+
"fork": fork,
|
|
1647
|
+
"pi_args": pi_args,
|
|
1648
|
+
"created_at": utcnow(),
|
|
1649
|
+
}
|
|
1650
|
+
server = listen_control(job_dir)
|
|
1651
|
+
except BaseException:
|
|
1652
|
+
shutil.rmtree(job_dir, ignore_errors=True)
|
|
1653
|
+
if lock_fd is not None:
|
|
1654
|
+
os.close(lock_fd)
|
|
1655
|
+
raise
|
|
1656
|
+
return start_run(job_dir, lock_fd, server, job, prompt, background, timeout, "new")
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def cmd_resume(argv):
|
|
1660
|
+
args = Args(argv, ("--timeout",), ("--background",))
|
|
1661
|
+
if len(args.positional) < 1:
|
|
1662
|
+
raise usage_error("resume needs a job name and a prompt")
|
|
1663
|
+
name = args.positional.pop(0)
|
|
1664
|
+
background = "--background" in args.flags
|
|
1665
|
+
timeout = run_timeout(args, background)
|
|
1666
|
+
prompt = read_message(args, "prompt")
|
|
1667
|
+
job_dir = find_job(name)
|
|
1668
|
+
check_depth()
|
|
1669
|
+
check_pi_bin()
|
|
1670
|
+
# A runner holds the lock while the job runs, so taking it proves that
|
|
1671
|
+
# nothing runs, and keeps it so until the new runner holds it.
|
|
1672
|
+
lock_fd = job_lock(
|
|
1673
|
+
job_dir, f"job {name} is running; use steer, or wait for it to finish"
|
|
1674
|
+
)
|
|
1675
|
+
try:
|
|
1676
|
+
job = read_json(job_path(job_dir, "job.json"))
|
|
1677
|
+
if not job:
|
|
1678
|
+
raise CommandError(f"job {name} has no readable job.json")
|
|
1679
|
+
if not os.path.isdir(job["cwd"]):
|
|
1680
|
+
raise CommandError(
|
|
1681
|
+
"the job's working directory no longer exists: {}".format(job["cwd"])
|
|
1682
|
+
)
|
|
1683
|
+
server = listen_control(job_dir)
|
|
1684
|
+
except BaseException:
|
|
1685
|
+
os.close(lock_fd)
|
|
1686
|
+
raise
|
|
1687
|
+
if not find_session_file(job["session_dir"], name):
|
|
1688
|
+
note(f"no session file found for {name}; the agent starts a new conversation")
|
|
1689
|
+
return start_run(
|
|
1690
|
+
job_dir, lock_fd, server, job, prompt, background, timeout, "resume"
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def cmd_wait(argv):
|
|
1695
|
+
args = Args(argv, ("--timeout",), ())
|
|
1696
|
+
job_dir = find_job(one_name(args, "wait"))
|
|
1697
|
+
timeout = (
|
|
1698
|
+
parse_seconds(args.values["--timeout"], "--timeout")
|
|
1699
|
+
if "--timeout" in args.values
|
|
1700
|
+
else None
|
|
1701
|
+
)
|
|
1702
|
+
end = None if timeout is None else time.monotonic() + timeout
|
|
1703
|
+
while True:
|
|
1704
|
+
status = job_state(job_dir)
|
|
1705
|
+
if status != "running":
|
|
1706
|
+
return print_result(job_dir, status)
|
|
1707
|
+
if end is not None and time.monotonic() >= end:
|
|
1708
|
+
lines = (
|
|
1709
|
+
read_text(job_path(job_dir, "progress.log"), "") or ""
|
|
1710
|
+
).splitlines()
|
|
1711
|
+
sys.stdout.write(
|
|
1712
|
+
f"job: {os.path.basename(job_dir)}\nstatus: running\nwaited_s: {format_seconds(timeout)}\n"
|
|
1713
|
+
)
|
|
1714
|
+
if lines:
|
|
1715
|
+
sys.stdout.write(f"last_progress: {lines[-1]}\n")
|
|
1716
|
+
return EXIT_STILL_RUNNING
|
|
1717
|
+
time.sleep(
|
|
1718
|
+
WAIT_POLL_S
|
|
1719
|
+
if end is None
|
|
1720
|
+
else max(0.0, min(WAIT_POLL_S, end - time.monotonic()))
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
def cmd_result(argv):
|
|
1725
|
+
job_dir = find_job(one_name(Args(argv, (), ()), "result"))
|
|
1726
|
+
status = job_state(job_dir)
|
|
1727
|
+
if status == "running":
|
|
1728
|
+
raise CommandError(
|
|
1729
|
+
f"job {os.path.basename(job_dir)} is still running; use wait"
|
|
1730
|
+
)
|
|
1731
|
+
return print_result(job_dir, status)
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def connect_runner(job_dir):
|
|
1735
|
+
"""Connect to the job's runner; NotRunning when no runner listens.
|
|
1736
|
+
|
|
1737
|
+
A refused or missing socket means no runner listens. While the lock is
|
|
1738
|
+
held anyway, a starter is between taking the lock and binding the
|
|
1739
|
+
socket, or the runner is exiting, so the connection is retried for a
|
|
1740
|
+
moment.
|
|
1741
|
+
"""
|
|
1742
|
+
path = socket_path(job_dir)
|
|
1743
|
+
end = time.monotonic() + LOCK_TIMEOUT_S
|
|
1744
|
+
while True:
|
|
1745
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
1746
|
+
try:
|
|
1747
|
+
sock.connect(path)
|
|
1748
|
+
return sock
|
|
1749
|
+
except OSError:
|
|
1750
|
+
sock.close()
|
|
1751
|
+
if job_state(job_dir) != "running":
|
|
1752
|
+
raise NotRunning()
|
|
1753
|
+
if time.monotonic() >= end:
|
|
1754
|
+
raise CommandError(
|
|
1755
|
+
f"the runner of job {os.path.basename(job_dir)} does not accept requests"
|
|
1756
|
+
)
|
|
1757
|
+
time.sleep(0.05)
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def send_request(job_dir, request, timeout_message):
|
|
1761
|
+
"""Send one request to the job's runner and return its reply.
|
|
1762
|
+
|
|
1763
|
+
Returns None when the runner closed the connection without a reply,
|
|
1764
|
+
which happens only when it exits. Fails with timeout_message when no
|
|
1765
|
+
reply arrives within ACK_TIMEOUT_S.
|
|
1766
|
+
"""
|
|
1767
|
+
sock = connect_runner(job_dir)
|
|
1768
|
+
try:
|
|
1769
|
+
sock.settimeout(ACK_TIMEOUT_S)
|
|
1770
|
+
sock.sendall(json.dumps(request).encode("utf-8") + b"\n")
|
|
1771
|
+
line = sock.makefile("rb").readline()
|
|
1772
|
+
except socket.timeout: # noqa: UP041
|
|
1773
|
+
# Python 3.9 uses a separate exception for socket timeouts.
|
|
1774
|
+
raise CommandError(timeout_message)
|
|
1775
|
+
except OSError:
|
|
1776
|
+
line = b""
|
|
1777
|
+
finally:
|
|
1778
|
+
sock.close()
|
|
1779
|
+
try:
|
|
1780
|
+
reply = json.loads(line.decode("utf-8", "replace"))
|
|
1781
|
+
except ValueError:
|
|
1782
|
+
return None
|
|
1783
|
+
return reply if isinstance(reply, dict) else None
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
def send_control(job_dir, request):
|
|
1787
|
+
name = os.path.basename(job_dir)
|
|
1788
|
+
reply = send_request(
|
|
1789
|
+
job_dir,
|
|
1790
|
+
request,
|
|
1791
|
+
f"the runner of job {name} did not reply within {int(ACK_TIMEOUT_S)} seconds",
|
|
1792
|
+
)
|
|
1793
|
+
if reply is None:
|
|
1794
|
+
if wait_while(lambda: job_state(job_dir) == "running", 2):
|
|
1795
|
+
raise NotRunning()
|
|
1796
|
+
raise CommandError(
|
|
1797
|
+
f"the runner of job {name} closed the connection without a reply"
|
|
1798
|
+
)
|
|
1799
|
+
if not reply.get("ok"):
|
|
1800
|
+
raise CommandError(reply.get("error") or "request failed")
|
|
1801
|
+
return reply
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def cmd_steer(argv):
|
|
1805
|
+
args = Args(argv, (), ())
|
|
1806
|
+
if len(args.positional) < 1:
|
|
1807
|
+
raise usage_error("steer needs a job name and a message")
|
|
1808
|
+
name = args.positional.pop(0)
|
|
1809
|
+
message = read_message(args, "message")
|
|
1810
|
+
job_dir = find_job(name)
|
|
1811
|
+
reply = send_control(job_dir, {"type": "steer", "message": message})
|
|
1812
|
+
sys.stdout.write(
|
|
1813
|
+
"job: {}\nsteer: {}\n".format(
|
|
1814
|
+
os.path.basename(job_dir), reply.get("disposition", "queued")
|
|
1815
|
+
)
|
|
1816
|
+
)
|
|
1817
|
+
return EXIT_OK
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
def cmd_abort(argv):
|
|
1821
|
+
job_dir = find_job(one_name(Args(argv, (), ()), "abort"))
|
|
1822
|
+
reply = send_control(job_dir, {"type": "abort"})
|
|
1823
|
+
sys.stdout.write(
|
|
1824
|
+
"job: {}\nabort: {}\n".format(
|
|
1825
|
+
os.path.basename(job_dir), reply.get("disposition", "requested")
|
|
1826
|
+
)
|
|
1827
|
+
)
|
|
1828
|
+
return EXIT_OK
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def kill_job(job_dir):
|
|
1832
|
+
"""Ask the job's runner to stop the run, and wait until it has exited.
|
|
1833
|
+
|
|
1834
|
+
The runner stops pi and records `killed` itself; this writes nothing.
|
|
1835
|
+
Whichever run is listening when the request arrives is the run that
|
|
1836
|
+
stops, so a run started after this returns is left alone.
|
|
1837
|
+
"""
|
|
1838
|
+
name = os.path.basename(job_dir)
|
|
1839
|
+
# `[_]` keeps the pattern from matching the command line of a shell that
|
|
1840
|
+
# runs this pkill, as `sh -c` and a bash tool do.
|
|
1841
|
+
pattern = "[_]_runner {} ".format(
|
|
1842
|
+
re.sub(r"([.^$*+?()\[\]{}|\\])", r"\\\1", job_dir)
|
|
1843
|
+
)
|
|
1844
|
+
stuck = f"the runner of job {name} is not responding; to stop it, run: pkill -KILL -f {shlex.quote(pattern)}"
|
|
1845
|
+
try:
|
|
1846
|
+
reply = send_request(job_dir, {"type": "kill"}, stuck)
|
|
1847
|
+
except NotRunning:
|
|
1848
|
+
reply = {"ok": False}
|
|
1849
|
+
|
|
1850
|
+
def running():
|
|
1851
|
+
return job_state(job_dir) == "running"
|
|
1852
|
+
|
|
1853
|
+
# A runner that is finishing answers NOT_RUNNING and records its own
|
|
1854
|
+
# status; one that exits before it answers leaves no reply.
|
|
1855
|
+
if not wait_while(running, RUNNER_STOP_GRACE_S):
|
|
1856
|
+
raise CommandError(stuck)
|
|
1857
|
+
if reply is not None and not reply.get("ok"):
|
|
1858
|
+
return f"job: {name}\nstatus: {job_state(job_dir)} (not running)\n"
|
|
1859
|
+
return f"job: {name}\nstatus: {job_state(job_dir)}\n"
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
def project_jobs(project):
|
|
1863
|
+
root = jobs_dir(project)
|
|
1864
|
+
try:
|
|
1865
|
+
names = sorted(os.listdir(root))
|
|
1866
|
+
except OSError:
|
|
1867
|
+
return []
|
|
1868
|
+
return [
|
|
1869
|
+
os.path.join(root, n) for n in names if os.path.isdir(os.path.join(root, n))
|
|
1870
|
+
]
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def cmd_kill(argv):
|
|
1874
|
+
args = Args(argv, (), ("--all",))
|
|
1875
|
+
if "--all" in args.flags:
|
|
1876
|
+
if args.positional:
|
|
1877
|
+
raise usage_error("kill takes a job name or --all, not both")
|
|
1878
|
+
running = [d for d in project_jobs(logical_cwd()) if job_state(d) == "running"]
|
|
1879
|
+
if not running:
|
|
1880
|
+
sys.stdout.write("no running jobs in this project\n")
|
|
1881
|
+
return EXIT_OK
|
|
1882
|
+
results = {}
|
|
1883
|
+
|
|
1884
|
+
def kill_one(job_dir):
|
|
1885
|
+
try:
|
|
1886
|
+
results[job_dir] = kill_job(job_dir)
|
|
1887
|
+
except CommandError as exc:
|
|
1888
|
+
results[job_dir] = f"job: {os.path.basename(job_dir)}\nerror: {exc}\n"
|
|
1889
|
+
|
|
1890
|
+
threads = [threading.Thread(target=kill_one, args=(d,)) for d in running]
|
|
1891
|
+
for thread in threads:
|
|
1892
|
+
thread.start()
|
|
1893
|
+
for thread in threads:
|
|
1894
|
+
thread.join()
|
|
1895
|
+
sys.stdout.write("".join(results[d] for d in running))
|
|
1896
|
+
return (
|
|
1897
|
+
EXIT_FAIL
|
|
1898
|
+
if any("\nerror: " in out for out in results.values())
|
|
1899
|
+
else EXIT_OK
|
|
1900
|
+
)
|
|
1901
|
+
job_dir = find_job(one_name(args, "kill"))
|
|
1902
|
+
sys.stdout.write(kill_job(job_dir))
|
|
1903
|
+
return EXIT_OK
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
def cmd_status(argv):
|
|
1907
|
+
job_dir = find_job(one_name(Args(argv, (), ()), "status"))
|
|
1908
|
+
job = read_json(job_path(job_dir, "job.json"), {})
|
|
1909
|
+
status = job_state(job_dir)
|
|
1910
|
+
name = os.path.basename(job_dir)
|
|
1911
|
+
session_dir = job.get("session_dir", "")
|
|
1912
|
+
sessions = find_session_file(session_dir, name)
|
|
1913
|
+
|
|
1914
|
+
def value(file_name):
|
|
1915
|
+
return (read_text(job_path(job_dir, file_name), "") or "").strip() or "-"
|
|
1916
|
+
|
|
1917
|
+
lines = [
|
|
1918
|
+
("name", name),
|
|
1919
|
+
("status", status),
|
|
1920
|
+
("mode", job.get("mode", "-")),
|
|
1921
|
+
("profile", job.get("profile", "-")),
|
|
1922
|
+
("model", model_label(job_dir, job)),
|
|
1923
|
+
("parent", os.path.basename(job["parent"]) if job.get("parent") else "-"),
|
|
1924
|
+
("cwd", job.get("cwd", "-")),
|
|
1925
|
+
("started_at", value("started_at")),
|
|
1926
|
+
("ended_at", value("ended_at")),
|
|
1927
|
+
("runs", value("runs")),
|
|
1928
|
+
("exit_code", value("exit_code")),
|
|
1929
|
+
("output_file", job_path(job_dir, "output.txt")),
|
|
1930
|
+
("session_file", sessions[-1] if sessions else "-"),
|
|
1931
|
+
(
|
|
1932
|
+
"session_cmd",
|
|
1933
|
+
"cd {} && {} --session-dir {} --session-id {}".format(
|
|
1934
|
+
shlex.quote(job.get("cwd", ".")),
|
|
1935
|
+
shlex.quote(pi_bin()),
|
|
1936
|
+
shlex.quote(session_dir),
|
|
1937
|
+
shlex.quote(name),
|
|
1938
|
+
),
|
|
1939
|
+
),
|
|
1940
|
+
]
|
|
1941
|
+
if status == "running":
|
|
1942
|
+
lines.append(("note", "open the session in pi only after the job stops"))
|
|
1943
|
+
sys.stdout.write("".join("{}: {}\n".format(*pair) for pair in lines))
|
|
1944
|
+
return EXIT_OK
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def cmd_peek(argv):
|
|
1948
|
+
args = Args(argv, ("-n",), ())
|
|
1949
|
+
count = args.values.get("-n", "20")
|
|
1950
|
+
if not count.isdigit():
|
|
1951
|
+
raise usage_error(f"-n needs a whole number of lines, got: {count}")
|
|
1952
|
+
job_dir = find_job(one_name(args, "peek"))
|
|
1953
|
+
lines = (read_text(job_path(job_dir, "progress.log"), "") or "").splitlines()
|
|
1954
|
+
tail = lines[-int(count) :] if int(count) else []
|
|
1955
|
+
sys.stdout.write("".join(line + "\n" for line in tail))
|
|
1956
|
+
return EXIT_OK
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
def cmd_list(argv):
|
|
1960
|
+
args = Args(argv, (), ("--all",))
|
|
1961
|
+
if args.positional:
|
|
1962
|
+
raise usage_error("list takes no job name")
|
|
1963
|
+
rows = []
|
|
1964
|
+
now = time.time()
|
|
1965
|
+
for job_dir in project_jobs(logical_cwd()):
|
|
1966
|
+
job = read_json(job_path(job_dir, "job.json"), {})
|
|
1967
|
+
status = job_state(job_dir)
|
|
1968
|
+
started = parse_utc(read_text(job_path(job_dir, "started_at"), "") or "")
|
|
1969
|
+
since = job_since(job_dir, status)
|
|
1970
|
+
if (
|
|
1971
|
+
"--all" not in args.flags
|
|
1972
|
+
and status != "running"
|
|
1973
|
+
and (since is None or now - since > RECENT_S)
|
|
1974
|
+
):
|
|
1975
|
+
continue
|
|
1976
|
+
rows.append(
|
|
1977
|
+
(
|
|
1978
|
+
started or 0,
|
|
1979
|
+
os.path.basename(job_dir),
|
|
1980
|
+
status,
|
|
1981
|
+
job.get("mode", "-"),
|
|
1982
|
+
os.path.basename(job["parent"]) if job.get("parent") else "-",
|
|
1983
|
+
format_age(now - since) if since is not None else "-",
|
|
1984
|
+
)
|
|
1985
|
+
)
|
|
1986
|
+
if not rows:
|
|
1987
|
+
sys.stdout.write("no jobs in this project\n")
|
|
1988
|
+
return EXIT_OK
|
|
1989
|
+
rows.sort()
|
|
1990
|
+
table = [("NAME", "STATUS", "MODE", "PARENT", "AGE")] + [row[1:] for row in rows]
|
|
1991
|
+
widths = [max(len(row[i]) for row in table) for i in range(4)]
|
|
1992
|
+
for row in table:
|
|
1993
|
+
cells = [row[i].ljust(widths[i]) for i in range(4)] + [row[4]]
|
|
1994
|
+
sys.stdout.write(" ".join(cells) + "\n")
|
|
1995
|
+
return EXIT_OK
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def cmd_profiles(argv):
|
|
1999
|
+
args = Args(argv, (), ())
|
|
2000
|
+
if args.positional:
|
|
2001
|
+
raise usage_error("profiles takes no arguments")
|
|
2002
|
+
profiles = sorted(
|
|
2003
|
+
(p for p in load_profiles(logical_cwd()).values() if p["enabled"]),
|
|
2004
|
+
key=lambda p: p["name"].lower(),
|
|
2005
|
+
)
|
|
2006
|
+
for profile in profiles:
|
|
2007
|
+
sys.stdout.write(
|
|
2008
|
+
"{}\n description: {}\n source: {}\n".format(
|
|
2009
|
+
profile["name"],
|
|
2010
|
+
" ".join(profile["description"].split()),
|
|
2011
|
+
profile["source"],
|
|
2012
|
+
)
|
|
2013
|
+
)
|
|
2014
|
+
if not profiles:
|
|
2015
|
+
sys.stdout.write("no profiles found\n")
|
|
2016
|
+
return EXIT_OK
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def remove_job(job_dir):
|
|
2020
|
+
name = os.path.basename(job_dir)
|
|
2021
|
+
lock_fd = job_lock(job_dir, f"job {name} is running; kill it first")
|
|
2022
|
+
try:
|
|
2023
|
+
job = read_json(job_path(job_dir, "job.json"), {})
|
|
2024
|
+
for path in find_session_file(job.get("session_dir", ""), name):
|
|
2025
|
+
remove(path)
|
|
2026
|
+
remove(socket_path(job_dir))
|
|
2027
|
+
shutil.rmtree(job_dir, ignore_errors=True)
|
|
2028
|
+
finally:
|
|
2029
|
+
os.close(lock_fd)
|
|
2030
|
+
sys.stdout.write(f"removed: {name}\n")
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
def cmd_rm(argv):
|
|
2034
|
+
args = Args(argv, (), ("--all",))
|
|
2035
|
+
if "--all" in args.flags:
|
|
2036
|
+
if args.positional:
|
|
2037
|
+
raise usage_error("rm takes a job name or --all, not both")
|
|
2038
|
+
finished = [d for d in project_jobs(logical_cwd()) if job_state(d) != "running"]
|
|
2039
|
+
if not finished:
|
|
2040
|
+
sys.stdout.write("no jobs to remove in this project\n")
|
|
2041
|
+
return EXIT_OK
|
|
2042
|
+
code = EXIT_OK
|
|
2043
|
+
for job_dir in finished:
|
|
2044
|
+
try:
|
|
2045
|
+
remove_job(job_dir)
|
|
2046
|
+
except CommandError as exc:
|
|
2047
|
+
sys.stdout.write(f"error: {exc}\n")
|
|
2048
|
+
code = EXIT_FAIL
|
|
2049
|
+
return code
|
|
2050
|
+
remove_job(find_job(one_name(args, "rm")))
|
|
2051
|
+
return EXIT_OK
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
COMMANDS = {
|
|
2055
|
+
"run": cmd_run,
|
|
2056
|
+
"resume": cmd_resume,
|
|
2057
|
+
"wait": cmd_wait,
|
|
2058
|
+
"result": cmd_result,
|
|
2059
|
+
"steer": cmd_steer,
|
|
2060
|
+
"abort": cmd_abort,
|
|
2061
|
+
"kill": cmd_kill,
|
|
2062
|
+
"status": cmd_status,
|
|
2063
|
+
"peek": cmd_peek,
|
|
2064
|
+
"list": cmd_list,
|
|
2065
|
+
"profiles": cmd_profiles,
|
|
2066
|
+
"rm": cmd_rm,
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
def main(argv):
|
|
2071
|
+
if sys.platform == "win32":
|
|
2072
|
+
note("Windows is not supported")
|
|
2073
|
+
return EXIT_FAIL
|
|
2074
|
+
if not argv:
|
|
2075
|
+
sys.stderr.write(USAGE + "\n")
|
|
2076
|
+
return EXIT_USAGE
|
|
2077
|
+
command, rest = argv[0], argv[1:]
|
|
2078
|
+
if command in ("-h", "--help", "help"):
|
|
2079
|
+
sys.stdout.write(USAGE + "\n")
|
|
2080
|
+
return EXIT_OK
|
|
2081
|
+
if command == "__runner":
|
|
2082
|
+
return cmd_runner(rest)
|
|
2083
|
+
handler = COMMANDS.get(command)
|
|
2084
|
+
if handler is None:
|
|
2085
|
+
note(f"unknown command: {command} (try --help)")
|
|
2086
|
+
return EXIT_USAGE
|
|
2087
|
+
try:
|
|
2088
|
+
return handler(rest)
|
|
2089
|
+
except CommandError as exc:
|
|
2090
|
+
note(f"error: {exc}")
|
|
2091
|
+
return exc.code
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
if __name__ == "__main__":
|
|
2095
|
+
try:
|
|
2096
|
+
sys.exit(main(sys.argv[1:]))
|
|
2097
|
+
except BrokenPipeError:
|
|
2098
|
+
sys.exit(EXIT_FAIL)
|
|
2099
|
+
except KeyboardInterrupt:
|
|
2100
|
+
sys.exit(130)
|