cdx-manager 0.12.4 → 0.14.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/README.md +132 -3
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +274 -33
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +177 -2
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +562 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +681 -0
- package/src/config.py +41 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_runtime.py +46 -11
- package/src/run_command.py +112 -4
- package/src/run_registry.py +53 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +574 -1426
- package/src/session_status.py +534 -0
- package/src/status_view.py +15 -34
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Run domain commands: run, runs, run-status, run-report, run-tail, select, schema.
|
|
2
|
+
|
|
3
|
+
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from ..cli_args import (
|
|
11
|
+
RUN_REPORT_USAGE,
|
|
12
|
+
RUN_STATUS_USAGE,
|
|
13
|
+
RUNS_USAGE,
|
|
14
|
+
_parse_run_args,
|
|
15
|
+
_parse_run_id_json_args,
|
|
16
|
+
_parse_run_tail_args,
|
|
17
|
+
_parse_runs_args,
|
|
18
|
+
_parse_schema_args,
|
|
19
|
+
_parse_select_args,
|
|
20
|
+
cdx_schema,
|
|
21
|
+
)
|
|
22
|
+
from ..cli_helpers import API_SCHEMA_VERSION, _json_failure, _json_success, _write_json
|
|
23
|
+
from ..errors import CdxError
|
|
24
|
+
from ..provider_runtime import _ensure_session_authentication, _headless_artifact_paths, _run_headless_provider_command
|
|
25
|
+
from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
|
|
26
|
+
from ..run_registry import RunRegistry, build_code_review_report
|
|
27
|
+
from ..run_usage import extract_run_usage
|
|
28
|
+
from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
|
|
29
|
+
from ..status_view import _now_timestamp, _priority_reset_timestamp
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def handle_select(rest, ctx):
|
|
33
|
+
parsed = _parse_select_args(rest)
|
|
34
|
+
selected = _select_headless_session(
|
|
35
|
+
ctx,
|
|
36
|
+
parsed["provider"],
|
|
37
|
+
min_reasoning_effort=parsed["min_reasoning_effort"],
|
|
38
|
+
require_ready=parsed["require_ready"],
|
|
39
|
+
force_refresh=parsed["refresh"],
|
|
40
|
+
)
|
|
41
|
+
# Derived from the ranking rather than written by hand: the previous
|
|
42
|
+
# literal omitted the reasoning-effort tie-break and described a candidate
|
|
43
|
+
# filter as a sort stage, and nothing failed when the sort key moved on
|
|
44
|
+
# without it.
|
|
45
|
+
policy = selection_policy()
|
|
46
|
+
if not selected:
|
|
47
|
+
message = "No suitable session found."
|
|
48
|
+
_write_json(ctx, _json_failure(
|
|
49
|
+
"select",
|
|
50
|
+
"no_suitable_session",
|
|
51
|
+
message,
|
|
52
|
+
provider=parsed["provider"],
|
|
53
|
+
min_reasoning_effort=parsed["min_reasoning_effort"],
|
|
54
|
+
require_ready=parsed["require_ready"],
|
|
55
|
+
selection_policy=policy,
|
|
56
|
+
))
|
|
57
|
+
return 1
|
|
58
|
+
session = selected["session"]
|
|
59
|
+
row = selected.get("row") or {}
|
|
60
|
+
_write_json(ctx, _json_success(
|
|
61
|
+
"select",
|
|
62
|
+
f"Selected session {session['name']}",
|
|
63
|
+
session=session["name"],
|
|
64
|
+
provider=session["provider"],
|
|
65
|
+
reason=_selection_reason(selected.get("decision")),
|
|
66
|
+
deciding_factor=selected.get("decision"),
|
|
67
|
+
selection_policy=policy,
|
|
68
|
+
min_reasoning_effort=parsed["min_reasoning_effort"],
|
|
69
|
+
reasoning_effort=row.get("reasoning_effort"),
|
|
70
|
+
priority=row.get("priority"),
|
|
71
|
+
available_pct=row.get("available_pct"),
|
|
72
|
+
auth_status=row.get("auth_status"),
|
|
73
|
+
))
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
def _selection_reason(decision):
|
|
77
|
+
"""Why this session won, for this call.
|
|
78
|
+
|
|
79
|
+
Names the factor that actually separated it from the runner-up instead of
|
|
80
|
+
the factor that usually does: the old fixed sentence claimed availability
|
|
81
|
+
decided even when priority or the alphabetical fallback did.
|
|
82
|
+
"""
|
|
83
|
+
if decision is None:
|
|
84
|
+
return "only suitable session"
|
|
85
|
+
return f"decided on {decision}: {FACTOR_DESCRIPTIONS[decision]}"
|
|
86
|
+
|
|
87
|
+
def _run_registry(ctx):
|
|
88
|
+
return RunRegistry(ctx["service"]["base_dir"])
|
|
89
|
+
|
|
90
|
+
DETACHED_RUN_ID_ENV = "CDX_RUN_ID"
|
|
91
|
+
|
|
92
|
+
RUN_TAIL_READ_BYTES = 1 << 20
|
|
93
|
+
|
|
94
|
+
def _tail_file_lines(path, lines):
|
|
95
|
+
"""Last `lines` lines of a run's output, safe to call while it is still being written.
|
|
96
|
+
|
|
97
|
+
Reads a bounded tail of the file in binary and decodes with replacement, so
|
|
98
|
+
provider output containing undecodable bytes returns those lines instead of
|
|
99
|
+
raising. A final line that is still mid-write is returned as-is: the caller
|
|
100
|
+
asked what the run is doing right now, not for a complete record.
|
|
101
|
+
"""
|
|
102
|
+
with open(path, "rb") as handle:
|
|
103
|
+
handle.seek(0, os.SEEK_END)
|
|
104
|
+
size = handle.tell()
|
|
105
|
+
truncated = size > RUN_TAIL_READ_BYTES
|
|
106
|
+
handle.seek(max(0, size - RUN_TAIL_READ_BYTES))
|
|
107
|
+
text = handle.read().decode("utf-8", errors="replace")
|
|
108
|
+
found = text.splitlines()
|
|
109
|
+
if truncated and found:
|
|
110
|
+
# The window opened mid-line, so the first entry is a fragment. Codex
|
|
111
|
+
# emits very long single-line JSON events, so this is reachable, and
|
|
112
|
+
# handing a caller half a line as if it were whole is worse than one
|
|
113
|
+
# line short.
|
|
114
|
+
found = found[1:]
|
|
115
|
+
return found[-lines:]
|
|
116
|
+
|
|
117
|
+
def _cdx_self_command():
|
|
118
|
+
"""argv prefix that re-runs this same cdx as a child process.
|
|
119
|
+
|
|
120
|
+
`--detach` re-invokes cdx rather than forking: the child then walks the
|
|
121
|
+
ordinary blocking run path, so registry bookkeeping, usage extraction,
|
|
122
|
+
history, and the code-review report all happen exactly once, in code that
|
|
123
|
+
is already tested, without a second implementation that only detached runs
|
|
124
|
+
exercise. `os.fork()` would avoid the re-exec but is not available on
|
|
125
|
+
Windows, which this CLI supports.
|
|
126
|
+
|
|
127
|
+
Always goes through `sys.executable -m`, never `sys.argv[0]`: the console
|
|
128
|
+
script has no reliable executable bit on Windows (`os.access(X_OK)` is true
|
|
129
|
+
for any existing file there) and a shebang script handed to CreateProcess
|
|
130
|
+
does not start at all.
|
|
131
|
+
"""
|
|
132
|
+
package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
133
|
+
return [sys.executable, "-m", f"{__package__}.cli"], package_root
|
|
134
|
+
|
|
135
|
+
def _detached_spawn_options():
|
|
136
|
+
"""Platform flags that keep the child alive after this process exits.
|
|
137
|
+
|
|
138
|
+
`start_new_session` is silently ignored on Windows, where a child stays
|
|
139
|
+
attached to the console and dies with the launcher unless it is explicitly
|
|
140
|
+
detached and given its own process group.
|
|
141
|
+
"""
|
|
142
|
+
if sys.platform == "win32":
|
|
143
|
+
detached_process = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
|
144
|
+
new_group = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
|
145
|
+
return {"creationflags": detached_process | new_group}
|
|
146
|
+
return {"start_new_session": True}
|
|
147
|
+
|
|
148
|
+
def _detached_child_argv(parsed, prompt_path, session_name, cwd):
|
|
149
|
+
"""Child argv for a detached run.
|
|
150
|
+
|
|
151
|
+
Always names the session the parent already resolved, never `--provider`:
|
|
152
|
+
the parent may have auto-selected an account, and letting the child select
|
|
153
|
+
again could land it on a different session than the one the launch payload
|
|
154
|
+
just reported to the caller.
|
|
155
|
+
"""
|
|
156
|
+
# The resolved absolute cwd, not the raw user string: the child is started
|
|
157
|
+
# from the package root, so a relative "--cwd ." would resolve there.
|
|
158
|
+
argv = ["run", session_name, "--cwd", cwd, "--prompt-file", prompt_path]
|
|
159
|
+
for flag, key in (
|
|
160
|
+
("--model", "model"),
|
|
161
|
+
("--kind", "kind"),
|
|
162
|
+
("--permission", "permission"),
|
|
163
|
+
):
|
|
164
|
+
value = parsed.get(key)
|
|
165
|
+
if value:
|
|
166
|
+
argv += [flag, str(value)]
|
|
167
|
+
# reasoning_effort and power are normalized to the same value upstream;
|
|
168
|
+
# passing one back is enough and avoids re-tripping the must-match check.
|
|
169
|
+
if parsed.get("reasoning_effort"):
|
|
170
|
+
argv += ["--reasoning-effort", str(parsed["reasoning_effort"])]
|
|
171
|
+
if parsed.get("timeout_seconds"):
|
|
172
|
+
argv += ["--timeout-seconds", str(parsed["timeout_seconds"])]
|
|
173
|
+
argv.append("--json")
|
|
174
|
+
return argv
|
|
175
|
+
|
|
176
|
+
DETACHED_PROMPT_SUFFIX = ".prompt.txt"
|
|
177
|
+
|
|
178
|
+
def _consume_detached_prompt_file(parsed, detached_run_id):
|
|
179
|
+
"""Delete the prompt file a detached launch staged for this child.
|
|
180
|
+
|
|
181
|
+
The prompt is arbitrary, often sensitive text, and the rest of the codebase
|
|
182
|
+
keeps it out of anything persisted (`REDACTED_PROMPT_ARG`, `sensitive_args`).
|
|
183
|
+
Leaving the staged copy behind would put a permanent cleartext prompt in the
|
|
184
|
+
session's log directory for every detached run. Only the child that was
|
|
185
|
+
handed a run id removes it, and only a file this code wrote.
|
|
186
|
+
"""
|
|
187
|
+
prompt_file = parsed.get("prompt_file")
|
|
188
|
+
if not detached_run_id or not prompt_file or not prompt_file.endswith(DETACHED_PROMPT_SUFFIX):
|
|
189
|
+
return
|
|
190
|
+
try:
|
|
191
|
+
os.remove(prompt_file)
|
|
192
|
+
except OSError:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
def _spawn_detached_run(parsed, prompt, artifacts, ctx, session_name, cwd):
|
|
196
|
+
"""Stage the prompt, then launch the child detached and return immediately.
|
|
197
|
+
|
|
198
|
+
The prompt is written beside the run's other artifacts rather than to a
|
|
199
|
+
temporary file so that nothing has to survive this process in order to
|
|
200
|
+
clean it up, and so the child cannot lose its input if the launcher exits
|
|
201
|
+
first.
|
|
202
|
+
"""
|
|
203
|
+
# transcript_path is "<prefix>.log"; the other artifacts share that prefix.
|
|
204
|
+
prefix = os.path.splitext(artifacts["transcript_path"])[0]
|
|
205
|
+
prompt_path = f"{prefix}{DETACHED_PROMPT_SUFFIX}"
|
|
206
|
+
launch_log_path = f"{prefix}.launch.log"
|
|
207
|
+
with open(prompt_path, "w", encoding="utf-8") as handle:
|
|
208
|
+
handle.write(prompt)
|
|
209
|
+
|
|
210
|
+
command, package_root = _cdx_self_command()
|
|
211
|
+
env = {**os.environ, DETACHED_RUN_ID_ENV: artifacts["run_id"]}
|
|
212
|
+
if package_root:
|
|
213
|
+
existing = env.get("PYTHONPATH")
|
|
214
|
+
env["PYTHONPATH"] = f"{package_root}{os.pathsep}{existing}" if existing else package_root
|
|
215
|
+
|
|
216
|
+
spawn = ctx.get("spawn_detached") or subprocess.Popen
|
|
217
|
+
try:
|
|
218
|
+
with open(launch_log_path, "w", encoding="utf-8", errors="replace") as log_file:
|
|
219
|
+
child = spawn(
|
|
220
|
+
command + _detached_child_argv(parsed, prompt_path, session_name, cwd),
|
|
221
|
+
stdout=log_file,
|
|
222
|
+
stderr=subprocess.STDOUT,
|
|
223
|
+
stdin=subprocess.DEVNULL,
|
|
224
|
+
# Detach so the run outlives the launcher, including when cdx
|
|
225
|
+
# was invoked over a non-interactive SSH command that closes as
|
|
226
|
+
# soon as it returns.
|
|
227
|
+
cwd=package_root or None,
|
|
228
|
+
env=env,
|
|
229
|
+
**_detached_spawn_options(),
|
|
230
|
+
)
|
|
231
|
+
except OSError as error:
|
|
232
|
+
# Surface as a CdxError so handle_run's own handler marks the already
|
|
233
|
+
# registered run failed, instead of letting a raw OSError escape as an
|
|
234
|
+
# unhandled traceback on a --json call.
|
|
235
|
+
raise CdxError(f"Failed to start detached cdx run: {error}", 126) from error
|
|
236
|
+
return {"pid": getattr(child, "pid", None), "launch_log_path": launch_log_path}
|
|
237
|
+
|
|
238
|
+
def handle_schema(rest, ctx):
|
|
239
|
+
_parse_schema_args(rest)
|
|
240
|
+
_write_json(ctx, _json_success("schema", "Schema loaded.", **cdx_schema()))
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
def handle_runs(rest, ctx):
|
|
244
|
+
parsed = _parse_runs_args(rest)
|
|
245
|
+
if not parsed["json"]:
|
|
246
|
+
raise CdxError(RUNS_USAGE)
|
|
247
|
+
since = parsed.get("since")
|
|
248
|
+
runs = _run_registry(ctx).list(limit=parsed["limit"], since=since)
|
|
249
|
+
warnings = []
|
|
250
|
+
if since is not None and parsed.get("limit_given"):
|
|
251
|
+
warnings.append({
|
|
252
|
+
"code": "limit_ignored_with_since",
|
|
253
|
+
"message": "--since returns every run completed after the cursor; --limit was ignored.",
|
|
254
|
+
})
|
|
255
|
+
_write_json(ctx, _json_success(
|
|
256
|
+
"runs",
|
|
257
|
+
"Runs loaded.",
|
|
258
|
+
warnings=warnings,
|
|
259
|
+
runs=runs,
|
|
260
|
+
since=since.isoformat() if since is not None else None,
|
|
261
|
+
))
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
def handle_run_tail(rest, ctx):
|
|
265
|
+
parsed = _parse_run_tail_args(rest)
|
|
266
|
+
run = _run_registry(ctx).get(parsed["run_id"])
|
|
267
|
+
if not run:
|
|
268
|
+
_write_json(ctx, _json_failure("run-tail", "run_not_found", f"Unknown run: {parsed['run_id']}"))
|
|
269
|
+
return 1
|
|
270
|
+
stdout_path = (run.get("artifacts") or {}).get("stdout_path")
|
|
271
|
+
if not stdout_path:
|
|
272
|
+
_write_json(ctx, _json_failure(
|
|
273
|
+
"run-tail",
|
|
274
|
+
"run_output_unavailable",
|
|
275
|
+
f"No stdout_path recorded for run: {parsed['run_id']}",
|
|
276
|
+
run_id=parsed["run_id"],
|
|
277
|
+
status=run.get("status"),
|
|
278
|
+
))
|
|
279
|
+
return 1
|
|
280
|
+
try:
|
|
281
|
+
lines = _tail_file_lines(stdout_path, parsed["lines"])
|
|
282
|
+
except FileNotFoundError:
|
|
283
|
+
if run.get("status") != "running":
|
|
284
|
+
_write_json(ctx, _json_failure(
|
|
285
|
+
"run-tail",
|
|
286
|
+
"run_output_unreadable",
|
|
287
|
+
f"Missing output file for a finished run: {stdout_path}",
|
|
288
|
+
run_id=parsed["run_id"],
|
|
289
|
+
stdout_path=stdout_path,
|
|
290
|
+
))
|
|
291
|
+
return 1
|
|
292
|
+
# A run registered a moment ago has not written its first byte yet.
|
|
293
|
+
# That is "no output so far", not a failure — the advertised flow is
|
|
294
|
+
# launch detached, then tail immediately, and a polling caller must not
|
|
295
|
+
# read that window as fatal.
|
|
296
|
+
lines = []
|
|
297
|
+
except OSError as error:
|
|
298
|
+
_write_json(ctx, _json_failure(
|
|
299
|
+
"run-tail",
|
|
300
|
+
"run_output_unreadable",
|
|
301
|
+
f"Failed to read {stdout_path}: {error}",
|
|
302
|
+
run_id=parsed["run_id"],
|
|
303
|
+
stdout_path=stdout_path,
|
|
304
|
+
))
|
|
305
|
+
return 1
|
|
306
|
+
_write_json(ctx, _json_success(
|
|
307
|
+
"run-tail",
|
|
308
|
+
"Run output loaded.",
|
|
309
|
+
run_id=parsed["run_id"],
|
|
310
|
+
status=run.get("status"),
|
|
311
|
+
stdout_path=stdout_path,
|
|
312
|
+
lines=lines,
|
|
313
|
+
))
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
def handle_run_status(rest, ctx):
|
|
317
|
+
parsed = _parse_run_id_json_args(rest, RUN_STATUS_USAGE)
|
|
318
|
+
run = _run_registry(ctx).get(parsed["run_id"])
|
|
319
|
+
if not run:
|
|
320
|
+
_write_json(ctx, _json_failure("run-status", "run_not_found", f"Unknown run: {parsed['run_id']}"))
|
|
321
|
+
return 1
|
|
322
|
+
_write_json(ctx, _json_success("run-status", "Run loaded.", run=run))
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
def handle_run_report(rest, ctx):
|
|
326
|
+
parsed = _parse_run_id_json_args(rest, RUN_REPORT_USAGE)
|
|
327
|
+
run = _run_registry(ctx).get(parsed["run_id"])
|
|
328
|
+
if not run:
|
|
329
|
+
_write_json(ctx, _json_failure("run-report", "run_not_found", f"Unknown run: {parsed['run_id']}"))
|
|
330
|
+
return 1
|
|
331
|
+
_write_json(ctx, _json_success("run-report", "Run report loaded.", report={
|
|
332
|
+
"run": run,
|
|
333
|
+
"final_payload": run.get("final_payload"),
|
|
334
|
+
"task_report": run.get("task_report"),
|
|
335
|
+
"artifacts": run.get("artifacts") or {},
|
|
336
|
+
"usage": run.get("usage"),
|
|
337
|
+
"error": run.get("error"),
|
|
338
|
+
}))
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
def handle_run(rest, ctx):
|
|
342
|
+
registry = None
|
|
343
|
+
run_id = None
|
|
344
|
+
selection_row = None
|
|
345
|
+
# pop, not get: the variable is consumed once. Left in the environment it is
|
|
346
|
+
# inherited by the provider process, and any nested `cdx run` the agent
|
|
347
|
+
# itself makes would claim this same run_id — deleting this run's registry
|
|
348
|
+
# record and truncating its stdout/stderr/transcript files.
|
|
349
|
+
detached_run_id = os.environ.pop(DETACHED_RUN_ID_ENV, None) or None
|
|
350
|
+
try:
|
|
351
|
+
parsed = _parse_run_args(rest)
|
|
352
|
+
session = None
|
|
353
|
+
if parsed["name"]:
|
|
354
|
+
session = ctx["service"]["get_session"](parsed["name"])
|
|
355
|
+
if not session:
|
|
356
|
+
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
357
|
+
if session.get("enabled", True) is False:
|
|
358
|
+
raise CdxError(f"Session is disabled: {parsed['name']}")
|
|
359
|
+
else:
|
|
360
|
+
selected = _select_headless_session(
|
|
361
|
+
ctx,
|
|
362
|
+
parsed["provider"],
|
|
363
|
+
min_reasoning_effort=parsed["reasoning_effort"],
|
|
364
|
+
require_ready=True,
|
|
365
|
+
# Cached by default: refreshing would make every auto-selected
|
|
366
|
+
# run pay for a provider probe. `--refresh` is there for callers
|
|
367
|
+
# that would rather pay than choose on stale data.
|
|
368
|
+
force_refresh=parsed.get("refresh", False),
|
|
369
|
+
)
|
|
370
|
+
if not selected:
|
|
371
|
+
_write_json(ctx, _json_failure(
|
|
372
|
+
"run",
|
|
373
|
+
"no_suitable_session",
|
|
374
|
+
"No suitable session found.",
|
|
375
|
+
launcher="cdx",
|
|
376
|
+
provider=parsed["provider"],
|
|
377
|
+
))
|
|
378
|
+
return 1
|
|
379
|
+
session = selected["session"]
|
|
380
|
+
selection_row = selected.get("row") or {}
|
|
381
|
+
|
|
382
|
+
cwd = os.path.abspath(parsed["cwd"])
|
|
383
|
+
if not os.path.isdir(cwd):
|
|
384
|
+
raise CdxError(f"Invalid cwd: {parsed['cwd']}")
|
|
385
|
+
prompt = read_run_prompt(parsed, stdin=ctx.get("prompt_stdin"))
|
|
386
|
+
_consume_detached_prompt_file(parsed, detached_run_id)
|
|
387
|
+
launch_updates = {}
|
|
388
|
+
if parsed.get("model"):
|
|
389
|
+
launch_updates["model"] = parsed["model"]
|
|
390
|
+
if parsed.get("permission"):
|
|
391
|
+
launch_updates["permission"] = parsed["permission"]
|
|
392
|
+
if parsed.get("reasoning_effort"):
|
|
393
|
+
launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
|
|
394
|
+
launch_updates["power"] = parsed["reasoning_effort"]
|
|
395
|
+
run_session = {
|
|
396
|
+
**session,
|
|
397
|
+
"launch": {
|
|
398
|
+
**(session.get("launch") or {}),
|
|
399
|
+
**launch_updates,
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
_ensure_session_authentication(
|
|
403
|
+
run_session,
|
|
404
|
+
ctx["service"],
|
|
405
|
+
spawn=ctx.get("spawn"),
|
|
406
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
407
|
+
env_override=ctx.get("env"),
|
|
408
|
+
stdin_is_tty=False,
|
|
409
|
+
behavior="launch",
|
|
410
|
+
signal_emitter=ctx.get("signal_emitter"),
|
|
411
|
+
trust_local_credentials=False,
|
|
412
|
+
)
|
|
413
|
+
registry = _run_registry(ctx)
|
|
414
|
+
# A detached child is handed its parent's run_id so both agree on the
|
|
415
|
+
# identity the launch payload already reported to the caller.
|
|
416
|
+
# pop, not get: the variable is consumed once. Left in the environment
|
|
417
|
+
# it is inherited by the provider process, and any nested `cdx run` the
|
|
418
|
+
# agent itself makes would claim this same run_id — deleting this run's
|
|
419
|
+
# registry record and truncating its stdout/stderr/transcript files.
|
|
420
|
+
artifacts = _headless_artifact_paths(run_session, run_id=detached_run_id)
|
|
421
|
+
run_id = artifacts["run_id"]
|
|
422
|
+
registry.start(
|
|
423
|
+
run_id,
|
|
424
|
+
kind=parsed.get("kind"),
|
|
425
|
+
session=run_session.get("name"),
|
|
426
|
+
provider=run_session.get("provider"),
|
|
427
|
+
model=parsed.get("model") or ((run_session.get("launch") or {}).get("model")),
|
|
428
|
+
cwd=cwd,
|
|
429
|
+
artifacts={
|
|
430
|
+
"transcript_path": artifacts.get("transcript_path"),
|
|
431
|
+
"stdout_path": artifacts.get("stdout_path"),
|
|
432
|
+
"stderr_path": artifacts.get("stderr_path"),
|
|
433
|
+
},
|
|
434
|
+
)
|
|
435
|
+
if parsed.get("detach"):
|
|
436
|
+
launch = _spawn_detached_run(parsed, prompt, artifacts, ctx, run_session["name"], cwd)
|
|
437
|
+
# Hand the record the child's pid: this process is about to exit,
|
|
438
|
+
# and a run pointing at a dead pid gets swept up as stale.
|
|
439
|
+
if launch.get("pid"):
|
|
440
|
+
registry.set_pid(run_id, launch["pid"])
|
|
441
|
+
_write_json(ctx, run_launch_payload(
|
|
442
|
+
API_SCHEMA_VERSION,
|
|
443
|
+
parsed,
|
|
444
|
+
run_session,
|
|
445
|
+
artifacts,
|
|
446
|
+
cwd=cwd,
|
|
447
|
+
pid=launch.get("pid"),
|
|
448
|
+
launch_log_path=launch.get("launch_log_path"),
|
|
449
|
+
selection_row=selection_row,
|
|
450
|
+
))
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
run_info = _run_headless_provider_command(
|
|
454
|
+
run_session,
|
|
455
|
+
cwd=cwd,
|
|
456
|
+
env_override=ctx.get("env"),
|
|
457
|
+
initial_prompt=prompt,
|
|
458
|
+
timeout_seconds=parsed.get("timeout_seconds"),
|
|
459
|
+
spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
|
|
460
|
+
run_id=run_id,
|
|
461
|
+
)
|
|
462
|
+
usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
|
|
463
|
+
run_info = {**run_info, "usage": usage}
|
|
464
|
+
ok = run_info.get("returncode") == 0
|
|
465
|
+
if ok:
|
|
466
|
+
final_payload = run_result_payload(
|
|
467
|
+
API_SCHEMA_VERSION, True, parsed, run_session, run_info=run_info,
|
|
468
|
+
selection_row=selection_row,
|
|
469
|
+
)
|
|
470
|
+
task_report = build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None
|
|
471
|
+
registry.finish(
|
|
472
|
+
run_id,
|
|
473
|
+
status="succeeded",
|
|
474
|
+
final_payload=final_payload,
|
|
475
|
+
run_info=run_info,
|
|
476
|
+
task_report=task_report,
|
|
477
|
+
)
|
|
478
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
479
|
+
"status": "success",
|
|
480
|
+
"cwd": cwd,
|
|
481
|
+
"exit_code": 0,
|
|
482
|
+
**run_info,
|
|
483
|
+
})
|
|
484
|
+
_write_json(ctx, final_payload)
|
|
485
|
+
return 0
|
|
486
|
+
message = "Provider process timed out." if run_info.get("timed_out") else "Provider process exited with a non-zero status."
|
|
487
|
+
error = CdxError(message, run_info.get("returncode") or 1)
|
|
488
|
+
final_payload = run_result_payload(
|
|
489
|
+
API_SCHEMA_VERSION,
|
|
490
|
+
False,
|
|
491
|
+
parsed,
|
|
492
|
+
run_session,
|
|
493
|
+
run_info=run_info,
|
|
494
|
+
error=error,
|
|
495
|
+
error_source="provider",
|
|
496
|
+
error_code="provider_timeout" if run_info.get("timed_out") else "provider_failed",
|
|
497
|
+
selection_row=selection_row,
|
|
498
|
+
)
|
|
499
|
+
registry.finish(
|
|
500
|
+
run_id,
|
|
501
|
+
status="timed_out" if run_info.get("timed_out") else "failed",
|
|
502
|
+
final_payload=final_payload,
|
|
503
|
+
run_info=run_info,
|
|
504
|
+
error=final_payload.get("error"),
|
|
505
|
+
task_report=build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None,
|
|
506
|
+
)
|
|
507
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
508
|
+
"status": "failed",
|
|
509
|
+
"cwd": cwd,
|
|
510
|
+
"error": str(error),
|
|
511
|
+
"exit_code": error.exit_code,
|
|
512
|
+
**run_info,
|
|
513
|
+
})
|
|
514
|
+
_write_json(ctx, final_payload)
|
|
515
|
+
return error.exit_code or 1
|
|
516
|
+
except CdxError as error:
|
|
517
|
+
run_info = getattr(error, "run_info", None)
|
|
518
|
+
final_payload = run_result_payload(
|
|
519
|
+
API_SCHEMA_VERSION,
|
|
520
|
+
False,
|
|
521
|
+
locals().get("parsed", {}) or {},
|
|
522
|
+
locals().get("session"),
|
|
523
|
+
run_info=run_info,
|
|
524
|
+
error=error,
|
|
525
|
+
error_source="cdx",
|
|
526
|
+
error_code=run_cdx_error_code(error),
|
|
527
|
+
selection_row=selection_row,
|
|
528
|
+
)
|
|
529
|
+
if registry and run_id:
|
|
530
|
+
registry.finish(
|
|
531
|
+
run_id,
|
|
532
|
+
status="failed",
|
|
533
|
+
final_payload=final_payload,
|
|
534
|
+
run_info=run_info,
|
|
535
|
+
error=final_payload.get("error"),
|
|
536
|
+
)
|
|
537
|
+
_write_json(ctx, final_payload)
|
|
538
|
+
return error.exit_code
|
|
539
|
+
|
|
540
|
+
def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_ready=False, force_refresh=False):
|
|
541
|
+
"""The best session for a headless run, using the one shared ranking.
|
|
542
|
+
|
|
543
|
+
Returns the winning session with its status row and why it won, or None if
|
|
544
|
+
no candidate survived the filters.
|
|
545
|
+
"""
|
|
546
|
+
sessions_by_name = {
|
|
547
|
+
session["name"]: session for session in ctx["service"]["list_sessions"]()
|
|
548
|
+
}
|
|
549
|
+
rows = ctx["service"]["get_status_rows"](force_refresh=force_refresh)
|
|
550
|
+
ordered, decision = rank_sessions(
|
|
551
|
+
rows,
|
|
552
|
+
_now_timestamp(),
|
|
553
|
+
_priority_reset_timestamp,
|
|
554
|
+
provider=provider,
|
|
555
|
+
require_ready=require_ready,
|
|
556
|
+
min_reasoning_effort=min_reasoning_effort,
|
|
557
|
+
)
|
|
558
|
+
for row in ordered:
|
|
559
|
+
session = sessions_by_name.get(row.get("session_name"))
|
|
560
|
+
if session:
|
|
561
|
+
return {"session": session, "row": row, "decision": decision}
|
|
562
|
+
return None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Launch settings domain commands: set, unset, and the power/perm/fast/model aliases.
|
|
2
|
+
|
|
3
|
+
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ..cli_args import _parse_launch_setting_alias_args, _parse_set_args, _parse_unset_args
|
|
7
|
+
from ..cli_helpers import _format_launch_config, _json_success, _write_json
|
|
8
|
+
from ..cli_render import _success
|
|
9
|
+
from ..errors import CdxError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _resolve_bulk_launch_targets(parsed, service):
|
|
13
|
+
sessions = service["list_sessions"]()
|
|
14
|
+
by_name = {session["name"]: session for session in sessions}
|
|
15
|
+
if parsed.get("name"):
|
|
16
|
+
session = by_name.get(parsed["name"])
|
|
17
|
+
if not session:
|
|
18
|
+
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
19
|
+
targets = [session]
|
|
20
|
+
elif parsed.get("sessions") == "all":
|
|
21
|
+
targets = sessions
|
|
22
|
+
elif parsed.get("sessions"):
|
|
23
|
+
targets = []
|
|
24
|
+
for name in parsed["sessions"]:
|
|
25
|
+
session = by_name.get(name)
|
|
26
|
+
if not session:
|
|
27
|
+
raise CdxError(f"Unknown session: {name}")
|
|
28
|
+
targets.append(session)
|
|
29
|
+
else:
|
|
30
|
+
targets = sessions
|
|
31
|
+
if parsed.get("provider"):
|
|
32
|
+
targets = [session for session in targets if session["provider"] == parsed["provider"]]
|
|
33
|
+
if not targets:
|
|
34
|
+
raise CdxError("No sessions matched.")
|
|
35
|
+
return targets
|
|
36
|
+
|
|
37
|
+
def _format_bulk_launch_summary(sessions):
|
|
38
|
+
names = [session["name"] for session in sessions]
|
|
39
|
+
if len(names) <= 8:
|
|
40
|
+
return ", ".join(names)
|
|
41
|
+
return ", ".join(names[:8]) + f", +{len(names) - 8} more"
|
|
42
|
+
|
|
43
|
+
def _apply_launch_settings(parsed, ctx, action="set"):
|
|
44
|
+
targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
|
|
45
|
+
sessions = [
|
|
46
|
+
ctx["service"]["set_launch_settings"](session["name"], parsed["settings"])
|
|
47
|
+
for session in targets
|
|
48
|
+
]
|
|
49
|
+
if len(sessions) == 1:
|
|
50
|
+
session = sessions[0]
|
|
51
|
+
message = f"Updated launch settings for {session['name']}"
|
|
52
|
+
else:
|
|
53
|
+
message = f"Updated launch settings for {len(sessions)} sessions: {_format_bulk_launch_summary(sessions)}"
|
|
54
|
+
if parsed["json"]:
|
|
55
|
+
payload = _json_success(
|
|
56
|
+
action,
|
|
57
|
+
message,
|
|
58
|
+
updated_count=len(sessions),
|
|
59
|
+
session=sessions[0] if len(sessions) == 1 else None,
|
|
60
|
+
sessions=sessions,
|
|
61
|
+
launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
|
|
62
|
+
)
|
|
63
|
+
_write_json(ctx, payload)
|
|
64
|
+
return 0
|
|
65
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
66
|
+
if len(sessions) == 1:
|
|
67
|
+
ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
def _clear_launch_settings(parsed, ctx, action="unset"):
|
|
71
|
+
targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
|
|
72
|
+
sessions = [
|
|
73
|
+
ctx["service"]["unset_launch_settings"](session["name"], parsed["keys"])
|
|
74
|
+
for session in targets
|
|
75
|
+
]
|
|
76
|
+
if len(sessions) == 1:
|
|
77
|
+
session = sessions[0]
|
|
78
|
+
message = f"Cleared launch settings for {session['name']}"
|
|
79
|
+
else:
|
|
80
|
+
message = f"Cleared launch settings for {len(sessions)} sessions: {_format_bulk_launch_summary(sessions)}"
|
|
81
|
+
if parsed["json"]:
|
|
82
|
+
payload = _json_success(
|
|
83
|
+
action,
|
|
84
|
+
message,
|
|
85
|
+
updated_count=len(sessions),
|
|
86
|
+
session=sessions[0] if len(sessions) == 1 else None,
|
|
87
|
+
sessions=sessions,
|
|
88
|
+
launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
|
|
89
|
+
)
|
|
90
|
+
_write_json(ctx, payload)
|
|
91
|
+
return 0
|
|
92
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
93
|
+
if len(sessions) == 1:
|
|
94
|
+
ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
def handle_set(rest, ctx):
|
|
98
|
+
return _apply_launch_settings(_parse_set_args(rest), ctx)
|
|
99
|
+
|
|
100
|
+
def handle_unset(rest, ctx):
|
|
101
|
+
return _clear_launch_settings(_parse_unset_args(rest), ctx)
|
|
102
|
+
|
|
103
|
+
def handle_launch_setting_alias(kind, rest, ctx):
|
|
104
|
+
parsed = _parse_launch_setting_alias_args(kind, rest)
|
|
105
|
+
if parsed.get("settings"):
|
|
106
|
+
return _apply_launch_settings(parsed, ctx, action=kind)
|
|
107
|
+
return _clear_launch_settings(parsed, ctx, action=kind)
|