Skip to content

Commit e8175d4

Browse files
authored
Merge pull request #3541 from Duang777/codex/core-experience-bug
fix(cli): report start-goal runtime startup failures
2 parents cafb746 + 8273015 commit e8175d4

4 files changed

Lines changed: 251 additions & 30 deletions

File tree

‎loopx/cli_commands/start_goal.py‎

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
build_start_goal_host_surface_selection_packet,
1313
render_start_goal_guided_markdown,
1414
)
15+
from ..control_plane.effect_runtime import (
16+
EffectRuntimeStartupError,
17+
MINIMUM_NODE_VERSION_TEXT,
18+
)
1519
from ._host_thread import current_host_thread_id
1620

1721
PrintPayload = Callable[
@@ -27,6 +31,79 @@
2731
)
2832
_FINE_GRAINED_PREFIX = re.compile(r"\A--fine-grained(?P<remainder>(?:\s[\s\S]*)?)\Z")
2933

34+
_EFFECT_RUNTIME_STARTUP_REMEDIATION_BY_CODE = {
35+
"node_unavailable": (
36+
f"Install or activate Node.js {MINIMUM_NODE_VERSION_TEXT} or newer "
37+
"on PATH, then run `loopx doctor --deep` and retry "
38+
"`loopx start-goal --guided`."
39+
),
40+
"startup_lock_timeout": (
41+
"Another LoopX TypeScript control-plane runtime may be starting. Run "
42+
"`loopx doctor --deep`, wait for the active startup to settle, and retry "
43+
"`loopx start-goal --guided`."
44+
),
45+
"runtime_launch_failed": (
46+
"Run `loopx doctor --deep` and retry `loopx start-goal --guided`. If the "
47+
"runtime still cannot start, repair or reinstall LoopX."
48+
),
49+
"runtime_exited_before_ready": (
50+
"Run `loopx doctor --deep` and retry `loopx start-goal --guided`. If the "
51+
"runtime exits again, repair or reinstall LoopX."
52+
),
53+
"runtime_startup_timeout": (
54+
"Run `loopx doctor --deep` and retry `loopx start-goal --guided`. If "
55+
"startup continues to time out, repair or reinstall LoopX."
56+
),
57+
"runtime_request_failed": (
58+
"Run `loopx doctor --deep` and retry `loopx start-goal --guided`. If "
59+
"requests continue to fail, repair or reinstall LoopX."
60+
),
61+
}
62+
_EFFECT_RUNTIME_STARTUP_DEFAULT_REMEDIATION = (
63+
"Run `loopx doctor --deep` and retry `loopx start-goal --guided`. If the "
64+
"TypeScript control-plane runtime remains unavailable, repair or reinstall LoopX."
65+
)
66+
67+
68+
def _effect_runtime_startup_recommended_action(diagnostic_code: str) -> str:
69+
return _EFFECT_RUNTIME_STARTUP_REMEDIATION_BY_CODE.get(
70+
diagnostic_code,
71+
_EFFECT_RUNTIME_STARTUP_DEFAULT_REMEDIATION,
72+
)
73+
74+
75+
def _effect_runtime_startup_failure_payload(
76+
exc: EffectRuntimeStartupError,
77+
) -> dict[str, object]:
78+
diagnostic_code = getattr(exc, "diagnostic_code", "runtime_startup_failed")
79+
return {
80+
"ok": False,
81+
"schema_version": "loopx_start_goal_guided_v0",
82+
"error": "LoopX TypeScript control-plane runtime is unavailable",
83+
"diagnostic_code": diagnostic_code,
84+
"runtime_requirement": {
85+
"runtime": "node",
86+
"minimum_node_version": MINIMUM_NODE_VERSION_TEXT,
87+
"required_for": ["start-goal", "control_plane"],
88+
},
89+
"recommended_action": _effect_runtime_startup_recommended_action(
90+
diagnostic_code
91+
),
92+
}
93+
94+
95+
def _render_start_goal_markdown(payload: dict[str, object]) -> str:
96+
if payload.get("ok") is not False:
97+
return render_start_goal_guided_markdown(payload)
98+
lines = ["# Guided Start Goal", "", f"- error: `{payload.get('error')}`"]
99+
for field in ("diagnostic_code", "recommended_action", "suggested_command"):
100+
value = payload.get(field)
101+
if not value:
102+
continue
103+
rendered = value if field == "recommended_action" else f"`{value}`"
104+
lines.append(f"- {field}: {rendered}")
105+
return "\n".join(lines) + "\n"
106+
30107

31108
def add_capability_route_argument(parser: argparse.ArgumentParser) -> None:
32109
parser.add_argument(
@@ -203,7 +280,7 @@ def handle_start_goal_command(
203280
"error": "`loopx start-goal` currently requires --guided",
204281
"suggested_command": "loopx start-goal --guided --goal-text '<goal text>'",
205282
}
206-
print_payload(payload, args.format, render_start_goal_guided_markdown)
283+
print_payload(payload, args.format, _render_start_goal_markdown)
207284
return 2
208285
try:
209286
goal_text, capability_route, fine_grained = _resolve_start_goal_input(args)
@@ -217,39 +294,49 @@ def handle_start_goal_command(
217294
"--slash-command-arguments='<exact /loopx arguments>'"
218295
),
219296
}
220-
print_payload(payload, args.format, render_start_goal_guided_markdown)
297+
print_payload(payload, args.format, _render_start_goal_markdown)
221298
return 2
222299
if not args.host_surface:
223-
payload = build_start_goal_host_surface_selection_packet(
300+
try:
301+
payload = build_start_goal_host_surface_selection_packet(
302+
project=Path(args.project),
303+
goal_id=args.goal_id,
304+
agent_id=args.agent_id,
305+
thread_id=current_host_thread_id(args),
306+
new_peer=bool(getattr(args, "new_peer", False)),
307+
cli_bin=args.cli_bin,
308+
goal_text=goal_text,
309+
available_capabilities=args.available_capabilities,
310+
capability_route=capability_route,
311+
fine_grained=fine_grained,
312+
include_command_pack_detail=bool(args.include_command_pack_detail),
313+
runtime_root_arg=runtime_root_arg,
314+
)
315+
except EffectRuntimeStartupError as exc:
316+
payload = _effect_runtime_startup_failure_payload(exc)
317+
print_payload(payload, args.format, _render_start_goal_markdown)
318+
return 1
319+
print_payload(payload, args.format, _render_start_goal_markdown)
320+
return 0
321+
try:
322+
payload = build_start_goal_guided_packet(
224323
project=Path(args.project),
225324
goal_id=args.goal_id,
226325
agent_id=args.agent_id,
227326
thread_id=current_host_thread_id(args),
228327
new_peer=bool(getattr(args, "new_peer", False)),
229328
cli_bin=args.cli_bin,
329+
host_surface=args.host_surface,
230330
goal_text=goal_text,
231331
available_capabilities=args.available_capabilities,
232332
capability_route=capability_route,
233333
fine_grained=fine_grained,
234334
include_command_pack_detail=bool(args.include_command_pack_detail),
235335
runtime_root_arg=runtime_root_arg,
236336
)
237-
print_payload(payload, args.format, render_start_goal_guided_markdown)
238-
return 0
239-
payload = build_start_goal_guided_packet(
240-
project=Path(args.project),
241-
goal_id=args.goal_id,
242-
agent_id=args.agent_id,
243-
thread_id=current_host_thread_id(args),
244-
new_peer=bool(getattr(args, "new_peer", False)),
245-
cli_bin=args.cli_bin,
246-
host_surface=args.host_surface,
247-
goal_text=goal_text,
248-
available_capabilities=args.available_capabilities,
249-
capability_route=capability_route,
250-
fine_grained=fine_grained,
251-
include_command_pack_detail=bool(args.include_command_pack_detail),
252-
runtime_root_arg=runtime_root_arg,
253-
)
254-
print_payload(payload, args.format, render_start_goal_guided_markdown)
337+
except EffectRuntimeStartupError as exc:
338+
payload = _effect_runtime_startup_failure_payload(exc)
339+
print_payload(payload, args.format, _render_start_goal_markdown)
340+
return 1
341+
print_payload(payload, args.format, _render_start_goal_markdown)
255342
return 0

‎loopx/control_plane/effect_runtime.py‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,10 @@ def _runtime_server_path() -> Path:
291291
def _node_executable() -> str:
292292
status, executable, _version = _probe_node()
293293
if status != "ready" or executable is None:
294-
raise RuntimeError(
294+
raise EffectRuntimeStartupError(
295295
f"LoopX Effect runtime requires Node.js {MINIMUM_NODE_VERSION_TEXT} "
296-
"or newer"
296+
"or newer",
297+
diagnostic_code="node_unavailable",
297298
)
298299
return executable
299300

@@ -606,28 +607,29 @@ def effect_runtime_request(
606607
request_id = str(uuid.uuid4())
607608
last_error: OSError | RuntimeError | None = None
608609
for attempt in range(2 if retry_safe else 1):
609-
info = _read_info(info_path, fingerprint=fingerprint)
610-
if info is None:
611-
info = _start_runtime(fingerprint=fingerprint, info_path=info_path)
612610
try:
611+
info = _read_info(info_path, fingerprint=fingerprint)
612+
if info is None:
613+
info = _start_runtime(fingerprint=fingerprint, info_path=info_path)
613614
return _request_with_info(
614615
info,
615616
request_id=request_id,
616617
method=method,
617618
params=params,
618619
timeout=timeout,
619620
)
620-
except EffectRuntimeRejected:
621-
raise
622-
except EffectRuntimeRemoteError:
621+
except (EffectRuntimeRemoteError, EffectRuntimeStartupError):
623622
raise
624623
except (OSError, RuntimeError) as exc:
625624
last_error = exc
626625
if attempt == 0 and retry_safe:
627626
info_path.unlink(missing_ok=True)
628627
continue
629628
break
630-
raise RuntimeError("TypeScript Effect runtime request failed") from last_error
629+
raise EffectRuntimeStartupError(
630+
"TypeScript Effect runtime request failed",
631+
diagnostic_code="runtime_request_failed",
632+
) from last_error
631633

632634

633635
def effect_runtime_result(

‎tests/control_plane/test_start_goal_compact_projection.py‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import pytest
1212

13+
import loopx.bootstrap_command_pack as command_pack_module
1314
from loopx.bootstrap_command_pack import (
1415
GUIDED_COMMAND_PACK_PROJECTION_SCHEMA_VERSION,
1516
build_loopx_bootstrap_command_pack,
@@ -25,6 +26,7 @@
2526
validate_shared_todo_options,
2627
validate_todo_add_options,
2728
)
29+
from loopx.control_plane import effect_runtime
2830

2931
GOAL_ID = "guided-projection-goal"
3032
AGENT_ID = "codex-guided-projection"
@@ -147,6 +149,122 @@ def _invoke_ark_start_goal_cli(
147149
return exit_code, payload
148150

149151

152+
def _block_effect_runtime_startup(
153+
monkeypatch: pytest.MonkeyPatch,
154+
*,
155+
diagnostic_code: str = "node_unavailable",
156+
) -> None:
157+
def unavailable(*_args: object, **_kwargs: object) -> object:
158+
raise effect_runtime.EffectRuntimeStartupError(
159+
"TypeScript Effect runtime could not serve the request",
160+
diagnostic_code=diagnostic_code,
161+
)
162+
163+
monkeypatch.setattr(
164+
command_pack_module,
165+
"effect_program_from_ordered_steps",
166+
unavailable,
167+
)
168+
169+
170+
@pytest.mark.parametrize(
171+
("diagnostic_code", "expected_action_fragment", "mentions_node_installation"),
172+
[
173+
("node_unavailable", "Install or activate Node.js", True),
174+
(
175+
"startup_lock_timeout",
176+
"wait for the active startup to settle",
177+
False,
178+
),
179+
("runtime_launch_failed", "runtime still cannot start", False),
180+
("runtime_exited_before_ready", "runtime exits again", False),
181+
("runtime_startup_timeout", "startup continues to time out", False),
182+
("runtime_request_failed", "requests continue to fail", False),
183+
("future_runtime_diagnostic", "runtime remains unavailable", False),
184+
],
185+
)
186+
def test_cli_reports_effect_runtime_startup_failure_without_traceback(
187+
tmp_path: Path,
188+
monkeypatch: pytest.MonkeyPatch,
189+
diagnostic_code: str,
190+
expected_action_fragment: str,
191+
mentions_node_installation: bool,
192+
) -> None:
193+
project = _write_connected_project(tmp_path)
194+
_block_effect_runtime_startup(monkeypatch, diagnostic_code=diagnostic_code)
195+
196+
output = io.StringIO()
197+
with contextlib.redirect_stdout(output):
198+
exit_code = cli_main(
199+
[
200+
"--format",
201+
"json",
202+
"start-goal",
203+
"--guided",
204+
"--project",
205+
str(project),
206+
"--goal-id",
207+
GOAL_ID,
208+
"--agent-id",
209+
AGENT_ID,
210+
"--host-surface",
211+
"shell",
212+
"--goal-text",
213+
GOAL_TEXT,
214+
]
215+
)
216+
217+
raw_output = output.getvalue()
218+
payload = json.loads(raw_output)
219+
assert exit_code == 1
220+
assert payload["ok"] is False
221+
assert payload["schema_version"] == "loopx_start_goal_guided_v0"
222+
assert payload["error"] == "LoopX TypeScript control-plane runtime is unavailable"
223+
assert payload["diagnostic_code"] == diagnostic_code
224+
assert payload["runtime_requirement"]["minimum_node_version"] == "22.6.0"
225+
assert "loopx doctor --deep" in payload["recommended_action"]
226+
assert expected_action_fragment in payload["recommended_action"]
227+
assert ("Node.js" in payload["recommended_action"]) is mentions_node_installation
228+
if not mentions_node_installation:
229+
assert "Install or activate Node.js" not in payload["recommended_action"]
230+
assert "Traceback" not in raw_output
231+
232+
233+
def test_cli_reports_effect_runtime_startup_failure_in_default_markdown(
234+
tmp_path: Path,
235+
monkeypatch: pytest.MonkeyPatch,
236+
) -> None:
237+
project = _write_connected_project(tmp_path)
238+
_block_effect_runtime_startup(monkeypatch)
239+
240+
output = io.StringIO()
241+
with contextlib.redirect_stdout(output):
242+
exit_code = cli_main(
243+
[
244+
"start-goal",
245+
"--guided",
246+
"--project",
247+
str(project),
248+
"--goal-id",
249+
GOAL_ID,
250+
"--agent-id",
251+
AGENT_ID,
252+
"--host-surface",
253+
"shell",
254+
"--goal-text",
255+
GOAL_TEXT,
256+
]
257+
)
258+
259+
raw_output = output.getvalue()
260+
assert exit_code == 1
261+
assert "# Guided Start Goal" in raw_output
262+
assert "LoopX TypeScript control-plane runtime is unavailable" in raw_output
263+
assert "diagnostic_code: `node_unavailable`" in raw_output
264+
assert "loopx doctor --deep" in raw_output
265+
assert "Traceback" not in raw_output
266+
267+
150268
def test_default_projection_preserves_host_actions_and_json_anchors(
151269
tmp_path: Path,
152270
) -> None:

‎tests/control_plane/test_turn_journal_runtime_readiness.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,20 @@ def test_missing_node_blocks_the_typescript_control_plane_and_is_actionable(
8787
assert "Node.js 22.6.0 or newer" in str(result["recommended_action"])
8888

8989

90+
def test_missing_node_request_raises_startup_diagnostic(
91+
tmp_path: Path,
92+
monkeypatch: pytest.MonkeyPatch,
93+
) -> None:
94+
monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: tmp_path)
95+
monkeypatch.setattr(effect_runtime.shutil, "which", lambda _name: None)
96+
97+
with pytest.raises(effect_runtime.EffectRuntimeStartupError) as error:
98+
effect_runtime.effect_runtime_result("runtime.ping", {})
99+
100+
assert error.value.diagnostic_code == "node_unavailable"
101+
assert "Node.js 22.6.0 or newer" in str(error.value)
102+
103+
90104
def test_old_node_is_reported_without_running_semantic_probe(
91105
monkeypatch: pytest.MonkeyPatch,
92106
) -> None:

0 commit comments

Comments
 (0)