relay-codex 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +176 -0
- package/docs/publishing.md +72 -0
- package/docs/troubleshooting.md +49 -0
- package/package.json +47 -4
- package/python/Menu.swift +188 -0
- package/python/broker.py +297 -0
- package/python/commands.py +900 -0
- package/python/compatibility.py +227 -0
- package/python/desktop.py +171 -0
- package/python/execution.py +551 -0
- package/python/launch.py +417 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +586 -0
- package/python/probe_native.py +91 -0
- package/python/processes.py +12 -0
- package/python/requirements.lock +6 -0
- package/python/requirements.txt +2 -0
- package/python/router.py +2288 -0
- package/python/runtime.py +97 -0
- package/python/service.py +737 -0
- package/python/settings.py +110 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +240 -0
- package/tests/test_audit.py +320 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +517 -0
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_pool.py +446 -0
- package/tests/test_relay.py +173 -0
- package/tests/test_router.py +314 -0
- package/tests/test_services.py +479 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Noninteractive Codex tasks using Relay's existing account/recovery controller."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from launch import (
|
|
11
|
+
arguments_only,
|
|
12
|
+
tokens,
|
|
13
|
+
translate,
|
|
14
|
+
backend_arguments,
|
|
15
|
+
working_directory,
|
|
16
|
+
additional_roots,
|
|
17
|
+
prepare_worktree,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def command(arguments):
|
|
22
|
+
for name, value, raw in tokens(arguments_only(arguments)):
|
|
23
|
+
if name is None and len(raw) == 1:
|
|
24
|
+
return value if value in {"exec", "e", "review"} else None
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def options(arguments, cwd):
|
|
29
|
+
parsed = list(tokens(arguments_only(arguments)))
|
|
30
|
+
positional = []
|
|
31
|
+
values, flags = {}, set()
|
|
32
|
+
allowed = {
|
|
33
|
+
"--config",
|
|
34
|
+
"--profile",
|
|
35
|
+
"--enable",
|
|
36
|
+
"--disable",
|
|
37
|
+
"--model",
|
|
38
|
+
"--image",
|
|
39
|
+
"--cd",
|
|
40
|
+
"--sandbox",
|
|
41
|
+
"--ask-for-approval",
|
|
42
|
+
"--permission-profile",
|
|
43
|
+
"--add-dir",
|
|
44
|
+
"--thread-source",
|
|
45
|
+
"--cyber-access-program",
|
|
46
|
+
"--output-schema",
|
|
47
|
+
"--color",
|
|
48
|
+
"--output-last-message",
|
|
49
|
+
"--base",
|
|
50
|
+
"--commit",
|
|
51
|
+
"--title",
|
|
52
|
+
"--search",
|
|
53
|
+
"--strict-config",
|
|
54
|
+
"--approve-for-me",
|
|
55
|
+
"--yolo",
|
|
56
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
57
|
+
"--skip-git-repo-check",
|
|
58
|
+
"--ephemeral",
|
|
59
|
+
"--json",
|
|
60
|
+
"--last",
|
|
61
|
+
"--all",
|
|
62
|
+
"--include-non-interactive",
|
|
63
|
+
"--uncommitted",
|
|
64
|
+
"--no-daemon",
|
|
65
|
+
"--no-alt-screen",
|
|
66
|
+
}
|
|
67
|
+
for name, value, raw in parsed:
|
|
68
|
+
if name is None:
|
|
69
|
+
positional.extend(raw[1:] if raw[:1] == ["--"] else raw)
|
|
70
|
+
else:
|
|
71
|
+
if name not in allowed:
|
|
72
|
+
raise ValueError("This exec option needs native Codex: " + name)
|
|
73
|
+
flags.add(name)
|
|
74
|
+
if name in {"--image", "--add-dir"}:
|
|
75
|
+
values.setdefault(name, []).append(value)
|
|
76
|
+
else:
|
|
77
|
+
values[name] = value
|
|
78
|
+
top = positional.pop(0)
|
|
79
|
+
mode = "review" if top == "review" else "start"
|
|
80
|
+
if top != "review" and positional[:1] in (["resume"], ["fork"], ["review"]):
|
|
81
|
+
mode = positional.pop(0)
|
|
82
|
+
target = None
|
|
83
|
+
if mode in {"resume", "fork"} and "--last" not in flags:
|
|
84
|
+
if not positional:
|
|
85
|
+
raise ValueError("Choose a session ID/name or use --last")
|
|
86
|
+
target = positional.pop(0)
|
|
87
|
+
if len(positional) > 1:
|
|
88
|
+
raise ValueError("Provide one quoted prompt, as with native Codex")
|
|
89
|
+
prompt = positional[0] if positional else None
|
|
90
|
+
return {
|
|
91
|
+
"mode": mode,
|
|
92
|
+
"target": target,
|
|
93
|
+
"prompt": prompt,
|
|
94
|
+
"values": values,
|
|
95
|
+
"flags": flags,
|
|
96
|
+
"cwd": working_directory(arguments, cwd),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Output:
|
|
101
|
+
def __init__(self, json_output=False):
|
|
102
|
+
self.json_output = json_output
|
|
103
|
+
self.done = asyncio.Event()
|
|
104
|
+
self.status = 0
|
|
105
|
+
self.last_message = ""
|
|
106
|
+
self.controller = None
|
|
107
|
+
self.usage = {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0}
|
|
108
|
+
self.thread = None
|
|
109
|
+
|
|
110
|
+
def write(self, value):
|
|
111
|
+
if self.json_output:
|
|
112
|
+
print(json.dumps(value), flush=True)
|
|
113
|
+
|
|
114
|
+
async def send(self, raw):
|
|
115
|
+
value = json.loads(raw)
|
|
116
|
+
method, params = value.get("method"), value.get("params", {})
|
|
117
|
+
if value.get("id") == "exec-start" and value.get("error"):
|
|
118
|
+
if value["error"].get("code") != -32001:
|
|
119
|
+
self.status = 1
|
|
120
|
+
self.write(
|
|
121
|
+
{
|
|
122
|
+
"type": "error",
|
|
123
|
+
"message": "Codex rejected the task. Check the saved conversation and options.",
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
self.done.set()
|
|
127
|
+
return
|
|
128
|
+
if self.thread and params.get("threadId") not in {None, self.thread}:
|
|
129
|
+
return
|
|
130
|
+
if "id" in value and method:
|
|
131
|
+
# Noninteractive execution cannot invent an approval or tool response.
|
|
132
|
+
self.status = 1
|
|
133
|
+
print(
|
|
134
|
+
"Task requires input. Resume the saved conversation in an interactive terminal.",
|
|
135
|
+
file=sys.stderr,
|
|
136
|
+
)
|
|
137
|
+
self.done.set()
|
|
138
|
+
return
|
|
139
|
+
if method == "turn/started":
|
|
140
|
+
self.write({"type": "turn.started"})
|
|
141
|
+
elif method == "thread/tokenUsage/updated":
|
|
142
|
+
usage = params.get("tokenUsage", {}).get("last", {})
|
|
143
|
+
self.usage = {
|
|
144
|
+
"input_tokens": usage.get("inputTokens", 0),
|
|
145
|
+
"cached_input_tokens": usage.get("cachedInputTokens", 0),
|
|
146
|
+
"output_tokens": usage.get("outputTokens", 0),
|
|
147
|
+
}
|
|
148
|
+
elif method in {"item/started", "item/completed"}:
|
|
149
|
+
native = params.get("item", {})
|
|
150
|
+
kind = native.get("type")
|
|
151
|
+
translated = {"id": native.get("id")}
|
|
152
|
+
if kind == "agentMessage":
|
|
153
|
+
translated.update(type="agent_message", text=native.get("text", ""))
|
|
154
|
+
if method == "item/completed":
|
|
155
|
+
self.last_message = native.get("text", "")
|
|
156
|
+
if not self.json_output:
|
|
157
|
+
print(self.last_message, flush=True)
|
|
158
|
+
elif kind == "reasoning":
|
|
159
|
+
translated.update(
|
|
160
|
+
type="reasoning", text="\n".join(native.get("summary", []))
|
|
161
|
+
)
|
|
162
|
+
elif kind == "commandExecution":
|
|
163
|
+
translated.update(
|
|
164
|
+
type="command_execution",
|
|
165
|
+
command=native.get("command", ""),
|
|
166
|
+
aggregated_output=native.get("aggregatedOutput", ""),
|
|
167
|
+
exit_code=native.get("exitCode"),
|
|
168
|
+
status="completed" if method == "item/completed" else "in_progress",
|
|
169
|
+
)
|
|
170
|
+
elif kind == "fileChange":
|
|
171
|
+
translated.update(
|
|
172
|
+
type="file_change",
|
|
173
|
+
changes=native.get("changes", []),
|
|
174
|
+
status="completed" if method == "item/completed" else "in_progress",
|
|
175
|
+
)
|
|
176
|
+
elif kind == "mcpToolCall":
|
|
177
|
+
translated.update(
|
|
178
|
+
type="mcp_tool_call",
|
|
179
|
+
server=native.get("server", ""),
|
|
180
|
+
tool=native.get("tool", ""),
|
|
181
|
+
arguments=native.get("arguments", {}),
|
|
182
|
+
result=native.get("result"),
|
|
183
|
+
error=native.get("error"),
|
|
184
|
+
status="completed" if method == "item/completed" else "in_progress",
|
|
185
|
+
)
|
|
186
|
+
elif kind == "webSearch":
|
|
187
|
+
action = native.get("action") or {}
|
|
188
|
+
translated.update(
|
|
189
|
+
type="web_search",
|
|
190
|
+
query=native.get("query")
|
|
191
|
+
or action.get("query")
|
|
192
|
+
or action.get("queries", []),
|
|
193
|
+
action=action,
|
|
194
|
+
)
|
|
195
|
+
elif kind == "plan":
|
|
196
|
+
translated.update(
|
|
197
|
+
type="todo_list", items=native.get("plan", native.get("items", []))
|
|
198
|
+
)
|
|
199
|
+
elif kind == "collabAgentToolCall":
|
|
200
|
+
translated.update(
|
|
201
|
+
type="collab_tool_call",
|
|
202
|
+
tool=native.get("tool"),
|
|
203
|
+
sender_thread_id=native.get("senderThreadId"),
|
|
204
|
+
receiver_thread_ids=native.get("receiverThreadIds", []),
|
|
205
|
+
prompt=native.get("prompt"),
|
|
206
|
+
agents_states=native.get("agentsStates", {}),
|
|
207
|
+
status=native.get("status"),
|
|
208
|
+
)
|
|
209
|
+
elif kind in {"imageView", "imageGeneration"}:
|
|
210
|
+
translated.update(
|
|
211
|
+
type="image_view" if kind == "imageView" else "image_generation",
|
|
212
|
+
path=native.get("path"),
|
|
213
|
+
status=native.get("status"),
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
return
|
|
217
|
+
self.write(
|
|
218
|
+
{
|
|
219
|
+
"type": "item.completed"
|
|
220
|
+
if method == "item/completed"
|
|
221
|
+
else "item.started",
|
|
222
|
+
"item": translated,
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
elif method == "turn/completed":
|
|
226
|
+
turn = params.get("turn", {})
|
|
227
|
+
if turn.get("status") == "completed":
|
|
228
|
+
self.write({"type": "turn.completed", "usage": self.usage})
|
|
229
|
+
self.done.set()
|
|
230
|
+
elif turn.get("status") in {"failed", "interrupted"}:
|
|
231
|
+
from pool import failure_kind
|
|
232
|
+
|
|
233
|
+
error = turn.get("error") or {}
|
|
234
|
+
recoverable = failure_kind(
|
|
235
|
+
error.get("codexErrorInfo"), error.get("message")
|
|
236
|
+
)
|
|
237
|
+
if not recoverable:
|
|
238
|
+
self.status = 1
|
|
239
|
+
self.write(
|
|
240
|
+
{
|
|
241
|
+
"type": "turn.failed",
|
|
242
|
+
"error": {
|
|
243
|
+
"message": "Task stopped. Review the saved conversation."
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
self.done.set()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def run(args, state, binary):
|
|
251
|
+
from pool import file_lock
|
|
252
|
+
|
|
253
|
+
async with file_lock(state / "metadata.lock", shared=True):
|
|
254
|
+
return await _run(args, state, binary)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def _run(args, state, binary):
|
|
258
|
+
from router import (
|
|
259
|
+
Controller,
|
|
260
|
+
RPC,
|
|
261
|
+
RouterError,
|
|
262
|
+
labels,
|
|
263
|
+
account_home,
|
|
264
|
+
resolve_resume_target,
|
|
265
|
+
)
|
|
266
|
+
from broker import ClientAccount
|
|
267
|
+
from pool import Pool
|
|
268
|
+
import settings
|
|
269
|
+
|
|
270
|
+
invocation_cwd = os.getcwd()
|
|
271
|
+
arguments = arguments_only(args.codex_args)
|
|
272
|
+
cwd = working_directory(arguments, invocation_cwd)
|
|
273
|
+
arguments, cwd = prepare_worktree(arguments, state, cwd)
|
|
274
|
+
config = options(arguments, invocation_cwd)
|
|
275
|
+
flags, values = config["flags"], config["values"]
|
|
276
|
+
if not Path(cwd).is_dir():
|
|
277
|
+
raise ValueError("Working directory does not exist")
|
|
278
|
+
if "--skip-git-repo-check" not in flags and config["mode"] != "review":
|
|
279
|
+
checked = subprocess.run(
|
|
280
|
+
["git", "-C", cwd, "rev-parse", "--show-toplevel"], capture_output=True
|
|
281
|
+
)
|
|
282
|
+
if checked.returncode:
|
|
283
|
+
raise ValueError(
|
|
284
|
+
"This directory is not a Git repository; pass --skip-git-repo-check to continue"
|
|
285
|
+
)
|
|
286
|
+
prompt = config["prompt"]
|
|
287
|
+
stdin = ""
|
|
288
|
+
if (
|
|
289
|
+
prompt == "-"
|
|
290
|
+
or prompt is None
|
|
291
|
+
and config["mode"] not in {"fork", "review"}
|
|
292
|
+
or not sys.stdin.isatty()
|
|
293
|
+
):
|
|
294
|
+
stdin = await asyncio.to_thread(sys.stdin.read)
|
|
295
|
+
if prompt == "-" or prompt is None:
|
|
296
|
+
prompt = stdin
|
|
297
|
+
elif stdin:
|
|
298
|
+
prompt += "\n<stdin>\n" + stdin + "\n</stdin>"
|
|
299
|
+
if not prompt and config["mode"] not in {"fork", "review"}:
|
|
300
|
+
raise ValueError("Provide a prompt or pipe instructions to stdin")
|
|
301
|
+
_, overrides = translate(arguments)
|
|
302
|
+
from launch import profile_config
|
|
303
|
+
|
|
304
|
+
private_config = profile_config(arguments)
|
|
305
|
+
if private_config:
|
|
306
|
+
overrides["config"] = private_config
|
|
307
|
+
overrides.setdefault("approvalPolicy", "never")
|
|
308
|
+
home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).resolve()
|
|
309
|
+
pool = Pool(state)
|
|
310
|
+
pool.assign("")
|
|
311
|
+
pool.touch(
|
|
312
|
+
name=args.name or "Codex " + config["mode"],
|
|
313
|
+
cwd=cwd,
|
|
314
|
+
mode="managed",
|
|
315
|
+
recovery_enabled=True,
|
|
316
|
+
)
|
|
317
|
+
enrolled = labels(state)
|
|
318
|
+
if not enrolled:
|
|
319
|
+
pool.close()
|
|
320
|
+
raise RouterError("Enroll an account first with relay setup")
|
|
321
|
+
output = Output("--json" in flags)
|
|
322
|
+
backend = None
|
|
323
|
+
controller = None
|
|
324
|
+
heartbeat = None
|
|
325
|
+
try:
|
|
326
|
+
|
|
327
|
+
async def factory():
|
|
328
|
+
return await RPC.start(
|
|
329
|
+
binary, home, extra_args=backend_arguments(arguments)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
backend = await factory()
|
|
333
|
+
controller = Controller(
|
|
334
|
+
[
|
|
335
|
+
ClientAccount(label, account_home(state, label), binary, pool)
|
|
336
|
+
for label in enrolled
|
|
337
|
+
],
|
|
338
|
+
backend,
|
|
339
|
+
pool=pool,
|
|
340
|
+
report=lambda message: print(message, file=sys.stderr),
|
|
341
|
+
)
|
|
342
|
+
controller.background = True
|
|
343
|
+
controller.project = cwd
|
|
344
|
+
controller.profile = args.profile
|
|
345
|
+
controller.preferences = settings.load(state, cwd, args.profile)
|
|
346
|
+
controller.retry_interval = controller.preferences["retry_interval"]
|
|
347
|
+
controller.retry_attempts = controller.preferences["retry_attempts"]
|
|
348
|
+
controller.interval = controller.preferences["unknown_reset_interval"]
|
|
349
|
+
controller.backend_factory = factory
|
|
350
|
+
controller.launch_overrides = overrides
|
|
351
|
+
controller.client = output
|
|
352
|
+
output.controller = controller
|
|
353
|
+
backend.handler = controller.event
|
|
354
|
+
init = await backend.initialize()
|
|
355
|
+
heartbeat = asyncio.create_task(controller.maintain())
|
|
356
|
+
model = values.get("--model")
|
|
357
|
+
first = enrolled.index(args.first) if args.first in enrolled else None
|
|
358
|
+
while True:
|
|
359
|
+
index, deadline = await controller.choose(model, preferred=first)
|
|
360
|
+
if index is None:
|
|
361
|
+
await controller.wait_until(deadline, controller.user_generation)
|
|
362
|
+
continue
|
|
363
|
+
try:
|
|
364
|
+
await controller.login(index)
|
|
365
|
+
break
|
|
366
|
+
except (RouterError, asyncio.TimeoutError):
|
|
367
|
+
await controller.mark(index, model, "unavailable")
|
|
368
|
+
roots = additional_roots(arguments, cwd)
|
|
369
|
+
thread_params = {"cwd": cwd, **overrides}
|
|
370
|
+
if model:
|
|
371
|
+
thread_params["model"] = model
|
|
372
|
+
if roots:
|
|
373
|
+
effective = await backend.call(
|
|
374
|
+
"config/read", {"cwd": cwd, "includeLayers": False}
|
|
375
|
+
)
|
|
376
|
+
configured = (
|
|
377
|
+
effective.get("config", {}).get("sandbox_workspace_write") or {}
|
|
378
|
+
).get("writable_roots", [])
|
|
379
|
+
thread_params["runtimeWorkspaceRoots"] = list(
|
|
380
|
+
dict.fromkeys([cwd, *configured, *roots])
|
|
381
|
+
)
|
|
382
|
+
if config["mode"] == "fork":
|
|
383
|
+
if not prompt and (
|
|
384
|
+
flags
|
|
385
|
+
& {
|
|
386
|
+
"--ephemeral",
|
|
387
|
+
"--image",
|
|
388
|
+
"--output-schema",
|
|
389
|
+
"--output-last-message",
|
|
390
|
+
"--cyber-access-program",
|
|
391
|
+
}
|
|
392
|
+
):
|
|
393
|
+
raise ValueError(
|
|
394
|
+
"A fork needs a prompt when images, output options, ephemeral mode, or an access program are requested"
|
|
395
|
+
)
|
|
396
|
+
if "--ephemeral" in flags:
|
|
397
|
+
thread_params["ephemeral"] = True
|
|
398
|
+
if "--thread-source" in values:
|
|
399
|
+
thread_params["threadSource"] = values["--thread-source"]
|
|
400
|
+
if config["mode"] in {"resume", "fork"}:
|
|
401
|
+
selection = [config["mode"]]
|
|
402
|
+
if config["target"]:
|
|
403
|
+
selection.append(config["target"])
|
|
404
|
+
selection.extend(
|
|
405
|
+
flag
|
|
406
|
+
for flag in ("--last", "--all", "--include-non-interactive")
|
|
407
|
+
if flag in flags
|
|
408
|
+
)
|
|
409
|
+
ident = await resolve_resume_target(selection, home, cwd, backend=backend)
|
|
410
|
+
if not ident:
|
|
411
|
+
raise RouterError("No matching saved conversation")
|
|
412
|
+
if config["mode"] == "resume" and pool.owner(ident) not in {
|
|
413
|
+
None,
|
|
414
|
+
pool.session,
|
|
415
|
+
}:
|
|
416
|
+
raise RouterError(
|
|
417
|
+
"Conversation is already running in another Relay session"
|
|
418
|
+
)
|
|
419
|
+
result = await backend.call(
|
|
420
|
+
"thread/" + config["mode"], {"threadId": ident, **thread_params}
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
if "--ephemeral" in flags:
|
|
424
|
+
thread_params["ephemeral"] = True
|
|
425
|
+
if "--thread-source" in values:
|
|
426
|
+
thread_params["threadSource"] = values["--thread-source"]
|
|
427
|
+
else:
|
|
428
|
+
thread_params["threadSource"] = "exec"
|
|
429
|
+
result = await backend.call("thread/start", thread_params)
|
|
430
|
+
thread = result["thread"]["id"]
|
|
431
|
+
if result["thread"].get("canAcceptDirectInput") is False or not pool.claim(
|
|
432
|
+
thread
|
|
433
|
+
):
|
|
434
|
+
raise RouterError(
|
|
435
|
+
"Conversation is open in another backend. Close it there or fork it."
|
|
436
|
+
)
|
|
437
|
+
controller.last_turn[thread] = {"threadId": thread}
|
|
438
|
+
controller.merge_settings(thread, result)
|
|
439
|
+
controller.merge_settings(thread, thread_params)
|
|
440
|
+
controller.persist(thread, role="foreground")
|
|
441
|
+
pool.touch(
|
|
442
|
+
thread=thread,
|
|
443
|
+
threads=[thread],
|
|
444
|
+
cwd=cwd,
|
|
445
|
+
model=controller.model_for(thread),
|
|
446
|
+
state="idle",
|
|
447
|
+
)
|
|
448
|
+
output.thread = thread
|
|
449
|
+
output.write({"type": "thread.started", "thread_id": thread})
|
|
450
|
+
if config["mode"] == "fork" and not prompt:
|
|
451
|
+
return 0
|
|
452
|
+
if config["mode"] == "review":
|
|
453
|
+
choices = [
|
|
454
|
+
key for key in ("--uncommitted", "--base", "--commit") if key in flags
|
|
455
|
+
]
|
|
456
|
+
if len(choices) > 1 or choices and prompt:
|
|
457
|
+
raise ValueError(
|
|
458
|
+
"Choose one review target, or provide custom review instructions"
|
|
459
|
+
)
|
|
460
|
+
if "--base" in values:
|
|
461
|
+
target = {"type": "baseBranch", "branch": values["--base"]}
|
|
462
|
+
elif "--commit" in values:
|
|
463
|
+
target = {
|
|
464
|
+
"type": "commit",
|
|
465
|
+
"sha": values["--commit"],
|
|
466
|
+
"title": values.get("--title"),
|
|
467
|
+
}
|
|
468
|
+
elif prompt:
|
|
469
|
+
target = {"type": "custom", "instructions": prompt}
|
|
470
|
+
else:
|
|
471
|
+
target = {"type": "uncommittedChanges"}
|
|
472
|
+
reviewed = await backend.call(
|
|
473
|
+
"review/start",
|
|
474
|
+
{"threadId": thread, "target": target, "delivery": "inline"},
|
|
475
|
+
)
|
|
476
|
+
review_thread = reviewed.get("reviewThreadId", thread)
|
|
477
|
+
if review_thread != thread:
|
|
478
|
+
if not pool.claim(review_thread):
|
|
479
|
+
raise RouterError("Review conversation is already owned")
|
|
480
|
+
controller.last_turn[review_thread] = {"threadId": review_thread}
|
|
481
|
+
controller.merge_settings(
|
|
482
|
+
review_thread, controller.thread_settings.get(thread, {})
|
|
483
|
+
)
|
|
484
|
+
thread = review_thread
|
|
485
|
+
output.thread = thread
|
|
486
|
+
else:
|
|
487
|
+
inputs = [{"type": "text", "text": prompt, "text_elements": []}]
|
|
488
|
+
for image_group in values.get("--image", []):
|
|
489
|
+
for image in image_group.split(","):
|
|
490
|
+
path = Path(image).expanduser()
|
|
491
|
+
inputs.append(
|
|
492
|
+
{
|
|
493
|
+
"type": "localImage",
|
|
494
|
+
"path": str(
|
|
495
|
+
(
|
|
496
|
+
path
|
|
497
|
+
if path.is_absolute()
|
|
498
|
+
else Path(invocation_cwd) / path
|
|
499
|
+
).resolve()
|
|
500
|
+
),
|
|
501
|
+
}
|
|
502
|
+
)
|
|
503
|
+
turn = {"threadId": thread, "input": inputs}
|
|
504
|
+
if "--output-schema" in values:
|
|
505
|
+
turn["outputSchema"] = json.loads(
|
|
506
|
+
Path(values["--output-schema"]).read_text()
|
|
507
|
+
)
|
|
508
|
+
if "--cyber-access-program" in values:
|
|
509
|
+
turn["cyberAccessProgram"] = values["--cyber-access-program"]
|
|
510
|
+
await controller.forward(
|
|
511
|
+
{"id": "exec-start", "method": "turn/start", "params": turn}, init
|
|
512
|
+
)
|
|
513
|
+
while not output.done.is_set():
|
|
514
|
+
try:
|
|
515
|
+
await asyncio.wait_for(output.done.wait(), 0.5)
|
|
516
|
+
except asyncio.TimeoutError:
|
|
517
|
+
saved = pool.intent(thread) or {}
|
|
518
|
+
if saved.get("review_required"):
|
|
519
|
+
raise RouterError(
|
|
520
|
+
"Task needs review. Resume the saved conversation before retrying."
|
|
521
|
+
)
|
|
522
|
+
if controller.stopping.is_set():
|
|
523
|
+
return 130
|
|
524
|
+
if "--output-last-message" in values:
|
|
525
|
+
Path(values["--output-last-message"]).write_text(output.last_message + "\n")
|
|
526
|
+
return output.status
|
|
527
|
+
finally:
|
|
528
|
+
if controller:
|
|
529
|
+
controller.stopping.set()
|
|
530
|
+
for task in (controller.recovery, controller.restart_task, heartbeat):
|
|
531
|
+
if task:
|
|
532
|
+
task.cancel()
|
|
533
|
+
await asyncio.gather(
|
|
534
|
+
*[
|
|
535
|
+
task
|
|
536
|
+
for task in (
|
|
537
|
+
controller.recovery,
|
|
538
|
+
controller.restart_task,
|
|
539
|
+
heartbeat,
|
|
540
|
+
)
|
|
541
|
+
if task
|
|
542
|
+
],
|
|
543
|
+
return_exceptions=True,
|
|
544
|
+
)
|
|
545
|
+
if controller.awake_process and controller.awake_process.returncode is None:
|
|
546
|
+
controller.awake_process.terminate()
|
|
547
|
+
await controller.awake_process.wait()
|
|
548
|
+
await controller.backend.close()
|
|
549
|
+
elif backend:
|
|
550
|
+
await backend.close()
|
|
551
|
+
pool.close()
|