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.
@@ -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)