cdx-manager 0.5.6 → 0.6.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/src/cli_render.py CHANGED
@@ -98,6 +98,19 @@ def _dim(text, use_color=False):
98
98
  return _style(text, "2", use_color)
99
99
 
100
100
 
101
+ def _format_launch_text(launch):
102
+ if not launch:
103
+ return "default"
104
+ parts = []
105
+ if launch.get("power"):
106
+ parts.append(launch["power"])
107
+ if launch.get("permission"):
108
+ parts.append(launch["permission"])
109
+ if launch.get("fast") is True:
110
+ parts.append("fast-on")
111
+ return "/".join(parts) or "default"
112
+
113
+
101
114
  def format_error(error, env=None, stderr=None):
102
115
  return _style(str(error), "31", _should_use_color(env or os.environ, stderr or sys.stderr))
103
116
 
@@ -105,35 +118,37 @@ def format_error(error, env=None, stderr=None):
105
118
  def _format_sessions(service, use_color=False):
106
119
  rows = service["format_list_rows"]()
107
120
  has_provider = any(r.get("provider") for r in rows)
121
+ has_launch = any(r.get("launch") for r in rows)
108
122
  headers = ["SESSION"]
109
123
  if has_provider:
110
124
  headers.append("PROVIDER")
111
125
  headers.append("STATUS")
126
+ if has_launch:
127
+ headers.append("LAUNCH")
112
128
  headers.append("UPDATED")
113
129
  headers = [_style(header, "1", use_color) for header in headers]
114
130
  table_rows = []
115
131
  for r in rows:
116
- parts = [r["name"]]
132
+ name = f"{r['name']}*" if r.get("active") else r["name"]
133
+ parts = [name]
117
134
  if has_provider:
118
135
  parts.append(r.get("provider") or "n/a")
119
136
  status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
120
137
  parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
138
+ if has_launch:
139
+ launch = r.get("launch") or {}
140
+ parts.append(_dim(_format_launch_text(launch), use_color))
121
141
  parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
122
142
  table_rows.append(parts)
123
143
  lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
124
144
  lines += [
125
145
  _style("Next actions:", "1", use_color),
126
- f" {_style('cdx add <name>', '36', use_color)}",
127
- f" {_style('cdx <name>', '36', use_color)}",
128
- f" {_style('cdx login <name>', '36', use_color)}",
129
- f" {_style('cdx logout <name>', '36', use_color)}",
130
- f" {_style('cdx context show', '36', use_color)}",
131
- f" {_style('cdx handoff <name>', '36', use_color)}",
132
- f" {_style('cdx handoff <source> <target>', '36', use_color)}",
133
- f" {_style('cdx disable <name>', '36', use_color)}",
134
- f" {_style('cdx enable <name>', '36', use_color)}",
135
- f" {_style('cdx ren <source> <dest>', '36', use_color)}",
136
- f" {_style('cdx rmv <name>', '36', use_color)}",
137
146
  f" {_style('cdx status', '36', use_color)}",
147
+ f" {_style('cdx ready', '36', use_color)}",
148
+ f" {_style('cdx set <name> --power medium --permission default --fast off', '36', use_color)}",
149
+ f" {_style('cdx handoff <source> <target>', '36', use_color)}",
150
+ f" {_style('cdx history', '36', use_color)}",
151
+ f" {_style('cdx help', '36', use_color)}",
152
+ f" {_style('cdx update', '36', use_color)}",
138
153
  ]
139
154
  return "\n".join(lines)
package/src/notify.py CHANGED
@@ -1,7 +1,9 @@
1
1
  import json
2
2
  import os
3
+ import shlex
3
4
  import subprocess
4
5
  import time
6
+ from datetime import datetime, timedelta
5
7
 
6
8
  from .errors import CdxError
7
9
  from .status_view import (
@@ -12,23 +14,27 @@ from .status_view import (
12
14
  )
13
15
 
14
16
 
17
+ NOTIFY_USAGE = "Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]"
18
+
19
+
15
20
  def parse_notify_args(args):
16
21
  json_flag = "--json" in args
17
22
  once = "--once" in args
18
23
  at_reset = "--at-reset" in args
19
24
  next_ready = "--next-ready" in args
20
25
  refresh = "--refresh" in args
26
+ schedule = "--schedule" in args
21
27
  poll = 60
22
28
  cleaned = []
23
29
  i = 0
24
30
  while i < len(args):
25
31
  arg = args[i]
26
- if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh"):
32
+ if arg in ("--json", "--once", "--at-reset", "--next-ready", "--refresh", "--schedule"):
27
33
  i += 1
28
34
  continue
29
35
  if arg == "--poll":
30
36
  if i + 1 >= len(args):
31
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
37
+ raise CdxError(NOTIFY_USAGE)
32
38
  try:
33
39
  poll = max(1, int(args[i + 1]))
34
40
  except ValueError as error:
@@ -43,15 +49,15 @@ def parse_notify_args(args):
43
49
  i += 1
44
50
  continue
45
51
  if arg.startswith("-"):
46
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
52
+ raise CdxError(NOTIFY_USAGE)
47
53
  cleaned.append(arg)
48
54
  i += 1
49
55
  if at_reset == next_ready:
50
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh] | cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
56
+ raise CdxError(NOTIFY_USAGE)
51
57
  if at_reset and len(cleaned) != 1:
52
- raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--refresh]")
58
+ raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh]")
53
59
  if next_ready and cleaned:
54
- raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--refresh]")
60
+ raise CdxError("Usage: cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh]")
55
61
  return {
56
62
  "name": cleaned[0] if cleaned else None,
57
63
  "mode": "at-reset" if at_reset else "next-ready",
@@ -59,6 +65,7 @@ def parse_notify_args(args):
59
65
  "once": once,
60
66
  "json": json_flag,
61
67
  "refresh": refresh,
68
+ "schedule": schedule,
62
69
  }
63
70
 
64
71
 
@@ -212,9 +219,225 @@ def _escape_applescript(value):
212
219
  return str(value).replace("\\", "\\\\").replace('"', '\\"')
213
220
 
214
221
 
222
+ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=None, now_fn=None):
223
+ import sys
224
+ spawn_sync = spawn_sync or subprocess.run
225
+ env = env or os.environ
226
+ now_fn = now_fn or time.time
227
+ target_timestamp = event.get("target_timestamp")
228
+ if target_timestamp is None:
229
+ raise CdxError(f"Cannot schedule notification: {event['message']}")
230
+ if target_timestamp <= now_fn():
231
+ return {
232
+ "scheduled": False,
233
+ "backend": "immediate",
234
+ "message": event["message"],
235
+ "target_timestamp": target_timestamp,
236
+ }
237
+
238
+ argv = _scheduled_notify_argv(parsed, env)
239
+ if sys.platform == "darwin":
240
+ result = _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env)
241
+ elif sys.platform == "win32":
242
+ result = _schedule_windows_task(parsed, event, argv, spawn_sync, env)
243
+ else:
244
+ result = _schedule_linux(parsed, event, argv, spawn_sync, env)
245
+ result["target_timestamp"] = target_timestamp
246
+ result["target_iso"] = _timestamp_to_local_iso(target_timestamp)
247
+ return result
248
+
249
+
250
+ def _scheduled_notify_argv(parsed, env):
251
+ executable = env.get("CDX_BIN") or shutil_which("cdx", env) or "cdx"
252
+ argv = [executable, "notify"]
253
+ if parsed["mode"] == "at-reset":
254
+ argv.append(parsed["name"])
255
+ argv.append("--at-reset")
256
+ else:
257
+ argv.append("--next-ready")
258
+ argv.append("--once")
259
+ argv.append("--refresh")
260
+ return argv
261
+
262
+
263
+ def _schedule_macos_launchd(base_dir, parsed, event, argv, spawn_sync, env):
264
+ label = _schedule_id("com.cdx-manager.notify", parsed, event)
265
+ schedule_dir = os.path.join(base_dir, "state", "notifications")
266
+ os.makedirs(schedule_dir, mode=0o700, exist_ok=True)
267
+ script_path = os.path.join(schedule_dir, f"{label}.sh")
268
+ launch_agents = os.path.join(os.path.expanduser("~"), "Library", "LaunchAgents")
269
+ os.makedirs(launch_agents, exist_ok=True)
270
+ plist_path = os.path.join(launch_agents, f"{label}.plist")
271
+ target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
272
+ script = _macos_schedule_script(argv, env, label, plist_path, script_path)
273
+ with open(script_path, "w", encoding="utf-8") as f:
274
+ f.write(script)
275
+ try:
276
+ os.chmod(script_path, 0o700)
277
+ except OSError:
278
+ pass
279
+ with open(plist_path, "w", encoding="utf-8") as f:
280
+ f.write(_launchd_plist(label, script_path, target))
281
+ result = _run_scheduler_command(["launchctl", "bootstrap", f"gui/{os.getuid()}", plist_path], spawn_sync, env)
282
+ if not result["ok"]:
283
+ if _scheduler_error_means_exists(result["error"]):
284
+ return {"scheduled": True, "existing": True, "backend": "launchd", "id": label, "path": plist_path}
285
+ result = _run_scheduler_command(["launchctl", "load", plist_path], spawn_sync, env)
286
+ if not result["ok"]:
287
+ if _scheduler_error_means_exists(result["error"]):
288
+ return {"scheduled": True, "existing": True, "backend": "launchd", "id": label, "path": plist_path}
289
+ raise CdxError(f"Failed to schedule notification with launchd: {result['error']}")
290
+ return {"scheduled": True, "existing": False, "backend": "launchd", "id": label, "path": plist_path}
291
+
292
+
293
+ def _macos_schedule_script(argv, env, label, plist_path, script_path):
294
+ lines = ["#!/bin/sh", f"export PATH={shlex.quote(env.get('PATH', '/usr/local/bin:/usr/bin:/bin'))}"]
295
+ if env.get("CDX_HOME"):
296
+ lines.append(f"export CDX_HOME={shlex.quote(env['CDX_HOME'])}")
297
+ lines.extend([
298
+ f"{' '.join(shlex.quote(str(part)) for part in argv)}",
299
+ "status=$?",
300
+ f"launchctl bootout {shlex.quote('gui/' + str(os.getuid()) + '/' + label)} >/dev/null 2>&1 || launchctl unload {shlex.quote(plist_path)} >/dev/null 2>&1 || true",
301
+ f"rm -f {shlex.quote(plist_path)} {shlex.quote(script_path)}",
302
+ "exit $status",
303
+ "",
304
+ ])
305
+ return "\n".join(lines)
306
+
307
+
308
+ def _launchd_plist(label, script_path, target):
309
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
310
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
311
+ <plist version="1.0">
312
+ <dict>
313
+ <key>Label</key>
314
+ <string>{_escape_xml(label)}</string>
315
+ <key>ProgramArguments</key>
316
+ <array>
317
+ <string>/bin/sh</string>
318
+ <string>{_escape_xml(script_path)}</string>
319
+ </array>
320
+ <key>StartCalendarInterval</key>
321
+ <dict>
322
+ <key>Month</key><integer>{target.month}</integer>
323
+ <key>Day</key><integer>{target.day}</integer>
324
+ <key>Hour</key><integer>{target.hour}</integer>
325
+ <key>Minute</key><integer>{target.minute}</integer>
326
+ </dict>
327
+ </dict>
328
+ </plist>
329
+ """
330
+
331
+
332
+ def _schedule_linux(parsed, event, argv, spawn_sync, env):
333
+ unit = _schedule_id("cdx-manager-notify", parsed, event)
334
+ target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
335
+ if shutil_which("systemd-run", env):
336
+ calendar = target.strftime("%Y-%m-%d %H:%M:%S")
337
+ result = _run_scheduler_command(
338
+ ["systemd-run", "--user", f"--unit={unit}", f"--on-calendar={calendar}", *argv],
339
+ spawn_sync,
340
+ env,
341
+ )
342
+ if result["ok"]:
343
+ return {"scheduled": True, "existing": False, "backend": "systemd", "id": unit}
344
+ if _scheduler_error_means_exists(result["error"]):
345
+ return {"scheduled": True, "existing": True, "backend": "systemd", "id": unit}
346
+ raise CdxError(f"Failed to schedule notification with systemd-run: {result['error']}")
347
+ if shutil_which("at", env):
348
+ at_time = target.strftime("%Y%m%d%H%M.%S")
349
+ command = " ".join(shlex.quote(str(part)) for part in argv)
350
+ result = _run_scheduler_command(["at", "-t", at_time], spawn_sync, env, input_text=f"{command}\n")
351
+ if result["ok"]:
352
+ return {"scheduled": True, "existing": False, "backend": "at", "id": unit}
353
+ raise CdxError(f"Failed to schedule notification with at: {result['error']}")
354
+ raise CdxError("Cannot schedule notification: install systemd-run or at, or run cdx notify without --schedule")
355
+
356
+
357
+ def _schedule_windows_task(parsed, event, argv, spawn_sync, env):
358
+ name = _schedule_id("cdx-manager-notify", parsed, event)
359
+ target = datetime.fromtimestamp(event["target_timestamp"]).astimezone()
360
+ command = subprocess.list2cmdline([str(part) for part in argv])
361
+ result = _run_scheduler_command([
362
+ "schtasks",
363
+ "/Create",
364
+ "/SC",
365
+ "ONCE",
366
+ "/TN",
367
+ name,
368
+ "/TR",
369
+ command,
370
+ "/ST",
371
+ target.strftime("%H:%M"),
372
+ "/SD",
373
+ target.strftime("%m/%d/%Y"),
374
+ "/F",
375
+ "/Z",
376
+ ], spawn_sync, env)
377
+ if not result["ok"]:
378
+ raise CdxError(f"Failed to schedule notification with Task Scheduler: {result['error']}")
379
+ return {"scheduled": True, "existing": False, "backend": "schtasks", "id": name}
380
+
381
+
382
+ def _run_scheduler_command(argv, spawn_sync, env, input_text=None):
383
+ try:
384
+ completed = spawn_sync(
385
+ argv,
386
+ env=env,
387
+ input=input_text,
388
+ capture_output=True,
389
+ text=True,
390
+ timeout=10,
391
+ )
392
+ except (FileNotFoundError, OSError) as error:
393
+ return {"ok": False, "error": str(error)}
394
+ return {
395
+ "ok": getattr(completed, "returncode", 0) == 0,
396
+ "error": (getattr(completed, "stderr", "") or getattr(completed, "stdout", "") or "").strip(),
397
+ }
398
+
399
+
400
+ def _schedule_id(prefix, parsed, event):
401
+ session = parsed.get("name") or event.get("session") or "next-ready"
402
+ safe_session = "".join(ch if ch.isalnum() else "-" for ch in str(session).lower()).strip("-") or "session"
403
+ return f"{prefix}.{parsed['mode']}.{safe_session}.{int(event['target_timestamp'])}"
404
+
405
+
406
+ def _scheduler_error_means_exists(error):
407
+ normalized = str(error or "").lower()
408
+ return any(fragment in normalized for fragment in (
409
+ "already exists",
410
+ "file exists",
411
+ "unit exists",
412
+ "unit already",
413
+ "service already",
414
+ "bootstrap failed: 5",
415
+ ))
416
+
417
+
418
+ def _timestamp_to_local_iso(timestamp):
419
+ return datetime.fromtimestamp(timestamp).astimezone().isoformat()
420
+
421
+
422
+ def _round_up_to_next_minute(value):
423
+ if value.second == 0 and value.microsecond == 0:
424
+ return value
425
+ return (value + timedelta(minutes=1)).replace(second=0, microsecond=0)
426
+
427
+
428
+ def _escape_xml(value):
429
+ return str(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
430
+
431
+
215
432
  def format_notify_event(event):
216
433
  return event["message"]
217
434
 
218
435
 
436
+ def format_scheduled_notification(schedule):
437
+ if schedule.get("scheduled"):
438
+ return f"Scheduled notification via {schedule['backend']} for {schedule['target_iso']}"
439
+ return schedule["message"]
440
+
441
+
219
442
  def notify_json(event):
220
443
  return json.dumps(event, indent=2)
@@ -12,6 +12,20 @@ from .errors import CdxError
12
12
 
13
13
 
14
14
  LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
15
+ LAUNCH_PERMISSION_ARGS = {
16
+ PROVIDER_CLAUDE: {
17
+ "review": ["--permission-mode", "plan"],
18
+ "default": ["--permission-mode", "default"],
19
+ "auto": ["--permission-mode", "auto"],
20
+ "full": ["--permission-mode", "bypassPermissions"],
21
+ },
22
+ PROVIDER_CODEX: {
23
+ "review": ["-s", "read-only", "-a", "on-request"],
24
+ "default": ["-s", "workspace-write", "-a", "on-request"],
25
+ "auto": ["-s", "workspace-write", "-a", "never"],
26
+ "full": ["-s", "danger-full-access", "-a", "never"],
27
+ },
28
+ }
15
29
 
16
30
 
17
31
  def _home_env_overrides(auth_home):
@@ -49,6 +63,35 @@ def _build_launch_transcript_path(session):
49
63
  )
50
64
 
51
65
 
66
+ def _launch_power(session):
67
+ launch = session.get("launch") or {}
68
+ power = launch.get("power")
69
+ if power:
70
+ return power
71
+ if launch.get("fast") is True:
72
+ return "low"
73
+ return None
74
+
75
+
76
+ def _launch_config_args(session):
77
+ launch = session.get("launch") or {}
78
+ args = []
79
+ power = _launch_power(session)
80
+ permission = launch.get("permission")
81
+ provider = session["provider"]
82
+ if provider == PROVIDER_CLAUDE:
83
+ if power:
84
+ args += ["--effort", power]
85
+ if permission:
86
+ args += LAUNCH_PERMISSION_ARGS[PROVIDER_CLAUDE].get(permission, [])
87
+ return args
88
+ if power:
89
+ args += ["-c", f'model_reasoning_effort="{power}"']
90
+ if permission:
91
+ args += LAUNCH_PERMISSION_ARGS[PROVIDER_CODEX].get(permission, [])
92
+ return args
93
+
94
+
52
95
  def _list_launch_transcript_paths(session, glob_fn=None):
53
96
  import glob
54
97
 
@@ -109,7 +152,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
109
152
  env_override = env_override or {}
110
153
  env = {**os.environ, **env_override}
111
154
  if session["provider"] == PROVIDER_CLAUDE:
112
- args = ["--name", session["name"]]
155
+ args = ["--name", session["name"]] + _launch_config_args(session)
113
156
  if initial_prompt:
114
157
  args.append(initial_prompt)
115
158
  return _wrap_launch_with_transcript(session, {
@@ -121,7 +164,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
121
164
  },
122
165
  "label": "claude",
123
166
  }, env=env)
124
- args = ["--no-alt-screen", "--cd", cwd]
167
+ args = ["--no-alt-screen", "--cd", cwd] + _launch_config_args(session)
125
168
  if initial_prompt:
126
169
  args.append(initial_prompt)
127
170
  return _wrap_launch_with_transcript(session, {
@@ -234,7 +277,7 @@ def _signal_name(sig):
234
277
 
235
278
  def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
236
279
  env_override=None, signal_emitter=None,
237
- initial_prompt=None):
280
+ initial_prompt=None, lifecycle_callback=None):
238
281
  spawn = spawn or subprocess.Popen
239
282
  spec = (
240
283
  _build_launch_spec(session, cwd=cwd, env_override=env_override, initial_prompt=initial_prompt)
@@ -250,11 +293,32 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
250
293
  **{k: v for k, v in current_spec.get("options", {}).items() if k != "stdio"},
251
294
  )
252
295
 
296
+ start_time = datetime.now(timezone.utc)
297
+
298
+ child_pid = None
299
+
300
+ def run_info(current_spec, returncode=None):
301
+ end_time = datetime.now(timezone.utc)
302
+ return {
303
+ "started_at": start_time.isoformat().replace("+00:00", "Z"),
304
+ "ended_at": end_time.isoformat().replace("+00:00", "Z"),
305
+ "duration_ms": int((end_time - start_time).total_seconds() * 1000),
306
+ "command": current_spec.get("command"),
307
+ "args": list(current_spec.get("args") or []),
308
+ "label": current_spec.get("label"),
309
+ "transcript_path": current_spec.get("transcript_path"),
310
+ "pid": child_pid,
311
+ "returncode": returncode,
312
+ }
313
+
253
314
  try:
254
315
  child = start_child(spec)
255
316
  except FileNotFoundError as error:
256
317
  spec = _fallback_launch_spec_or_raise(spec, error)
257
318
  child = start_child(spec)
319
+ child_pid = getattr(child, "pid", None) or os.getpid()
320
+ if lifecycle_callback:
321
+ lifecycle_callback("started", run_info(spec))
258
322
 
259
323
  forwarded_signal = None
260
324
  handlers = []
@@ -293,6 +357,9 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
293
357
  if forwarded_signal is None and child.returncode != 0 and _should_retry_without_transcript(spec):
294
358
  spec = _fallback_launch_spec_or_raise(spec)
295
359
  child = start_child(spec)
360
+ child_pid = getattr(child, "pid", None) or os.getpid()
361
+ if lifecycle_callback:
362
+ lifecycle_callback("started", run_info(spec))
296
363
  child.wait()
297
364
  finally:
298
365
  if use_emitter:
@@ -309,14 +376,26 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
309
376
  pass
310
377
 
311
378
  if forwarded_signal is not None:
312
- raise CdxError(
379
+ error = CdxError(
313
380
  f"{spec['label']} interrupted by {_signal_name(forwarded_signal)} for session {session['name']}",
314
381
  _signal_exit_code(forwarded_signal),
315
382
  )
383
+ error.run_info = run_info(spec, returncode=error.exit_code)
384
+ if lifecycle_callback:
385
+ lifecycle_callback("finished", error.run_info)
386
+ raise error
316
387
  if child.returncode != 0:
317
- raise CdxError(
388
+ error = CdxError(
318
389
  f"{spec['label']} exited with code {child.returncode} for session {session['name']}"
319
390
  )
391
+ error.run_info = run_info(spec, returncode=child.returncode)
392
+ if lifecycle_callback:
393
+ lifecycle_callback("finished", error.run_info)
394
+ raise error
395
+ info = run_info(spec, returncode=child.returncode)
396
+ if lifecycle_callback:
397
+ lifecycle_callback("finished", info)
398
+ return info
320
399
 
321
400
 
322
401
  def _fallback_launch_spec_or_raise(spec, original_error=None):