Skip to content

Commit 10917cb

Browse files
committed
fix(cli): report start-goal runtime startup failures
Signed-off-by: duang777 <duangjl007@gmail.com>
1 parent 1778334 commit 10917cb

5 files changed

Lines changed: 186 additions & 22 deletions

File tree

‎loopx/bootstrap_command_pack.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,6 +1698,23 @@ def rerun_start_goal(selected_agent_id: str) -> str:
16981698

16991699

17001700
def render_start_goal_guided_markdown(payload: dict[str, Any]) -> str:
1701+
if payload.get("ok") is False:
1702+
lines = [
1703+
"# Guided Start Goal",
1704+
"",
1705+
f"- error: `{payload.get('error')}`",
1706+
]
1707+
diagnostic_code = payload.get("diagnostic_code")
1708+
if diagnostic_code:
1709+
lines.append(f"- diagnostic_code: `{diagnostic_code}`")
1710+
recommended_action = payload.get("recommended_action")
1711+
if recommended_action:
1712+
lines.append(f"- recommended_action: {recommended_action}")
1713+
suggested_command = payload.get("suggested_command")
1714+
if suggested_command:
1715+
lines.append(f"- suggested_command: `{suggested_command}`")
1716+
return "\n".join(lines) + "\n"
1717+
17011718
def command_summary(value: Any) -> str:
17021719
parts = str(value or "").split()
17031720
return " ".join(parts[:3]) + (" ..." if len(parts) > 3 else "")

‎loopx/cli_commands/start_goal.py‎

Lines changed: 52 additions & 16 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[
@@ -28,6 +32,28 @@
2832
_FINE_GRAINED_PREFIX = re.compile(r"\A--fine-grained(?P<remainder>(?:\s[\s\S]*)?)\Z")
2933

3034

35+
def _effect_runtime_startup_failure_payload(
36+
exc: EffectRuntimeStartupError,
37+
) -> dict[str, object]:
38+
diagnostic_code = getattr(exc, "diagnostic_code", "runtime_startup_failed")
39+
return {
40+
"ok": False,
41+
"schema_version": "loopx_start_goal_guided_v0",
42+
"error": "LoopX TypeScript control-plane runtime is unavailable",
43+
"diagnostic_code": diagnostic_code,
44+
"runtime_requirement": {
45+
"runtime": "node",
46+
"minimum_node_version": MINIMUM_NODE_VERSION_TEXT,
47+
"required_for": ["start-goal", "control_plane"],
48+
},
49+
"recommended_action": (
50+
f"Install or activate Node.js {MINIMUM_NODE_VERSION_TEXT} or newer "
51+
"on PATH, then run `loopx doctor --deep` and retry "
52+
"`loopx start-goal --guided`."
53+
),
54+
}
55+
56+
3157
def add_capability_route_argument(parser: argparse.ArgumentParser) -> None:
3258
parser.add_argument(
3359
"--capability-route",
@@ -218,34 +244,44 @@ def handle_start_goal_command(
218244
print_payload(payload, args.format, render_start_goal_guided_markdown)
219245
return 2
220246
if not args.host_surface:
221-
payload = build_start_goal_host_surface_selection_packet(
247+
try:
248+
payload = build_start_goal_host_surface_selection_packet(
249+
project=Path(args.project),
250+
goal_id=args.goal_id,
251+
agent_id=args.agent_id,
252+
thread_id=current_host_thread_id(args),
253+
new_peer=bool(getattr(args, "new_peer", False)),
254+
cli_bin=args.cli_bin,
255+
goal_text=goal_text,
256+
available_capabilities=args.available_capabilities,
257+
capability_route=capability_route,
258+
fine_grained=fine_grained,
259+
include_command_pack_detail=bool(args.include_command_pack_detail),
260+
)
261+
except EffectRuntimeStartupError as exc:
262+
payload = _effect_runtime_startup_failure_payload(exc)
263+
print_payload(payload, args.format, render_start_goal_guided_markdown)
264+
return 1
265+
print_payload(payload, args.format, render_start_goal_guided_markdown)
266+
return 0
267+
try:
268+
payload = build_start_goal_guided_packet(
222269
project=Path(args.project),
223270
goal_id=args.goal_id,
224271
agent_id=args.agent_id,
225272
thread_id=current_host_thread_id(args),
226273
new_peer=bool(getattr(args, "new_peer", False)),
227274
cli_bin=args.cli_bin,
275+
host_surface=args.host_surface,
228276
goal_text=goal_text,
229277
available_capabilities=args.available_capabilities,
230278
capability_route=capability_route,
231279
fine_grained=fine_grained,
232280
include_command_pack_detail=bool(args.include_command_pack_detail),
233281
)
282+
except EffectRuntimeStartupError as exc:
283+
payload = _effect_runtime_startup_failure_payload(exc)
234284
print_payload(payload, args.format, render_start_goal_guided_markdown)
235-
return 0
236-
payload = build_start_goal_guided_packet(
237-
project=Path(args.project),
238-
goal_id=args.goal_id,
239-
agent_id=args.agent_id,
240-
thread_id=current_host_thread_id(args),
241-
new_peer=bool(getattr(args, "new_peer", False)),
242-
cli_bin=args.cli_bin,
243-
host_surface=args.host_surface,
244-
goal_text=goal_text,
245-
available_capabilities=args.available_capabilities,
246-
capability_route=capability_route,
247-
fine_grained=fine_grained,
248-
include_command_pack_detail=bool(args.include_command_pack_detail),
249-
)
285+
return 1
250286
print_payload(payload, args.format, render_start_goal_guided_markdown)
251287
return 0

‎loopx/control_plane/effect_runtime.py‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ def _runtime_server_path() -> Path:
9898
def _node_executable() -> str:
9999
status, executable, _version = _probe_node()
100100
if status != "ready" or executable is None:
101-
raise RuntimeError(
101+
raise EffectRuntimeStartupError(
102102
f"LoopX Effect runtime requires Node.js {MINIMUM_NODE_VERSION_TEXT} "
103-
"or newer"
103+
"or newer",
104+
diagnostic_code="node_unavailable",
104105
)
105106
return executable
106107

@@ -373,10 +374,10 @@ def effect_runtime_request(
373374
request_id = str(uuid.uuid4())
374375
last_error: OSError | RuntimeError | None = None
375376
for attempt in range(2 if retry_safe else 1):
376-
info = _read_info(info_path, fingerprint=fingerprint)
377-
if info is None:
378-
info = _start_runtime(fingerprint=fingerprint, info_path=info_path)
379377
try:
378+
info = _read_info(info_path, fingerprint=fingerprint)
379+
if info is None:
380+
info = _start_runtime(fingerprint=fingerprint, info_path=info_path)
380381
return _request_with_info(
381382
info,
382383
request_id=request_id,
@@ -386,13 +387,18 @@ def effect_runtime_request(
386387
)
387388
except EffectRuntimeRejected:
388389
raise
390+
except EffectRuntimeStartupError:
391+
raise
389392
except (OSError, RuntimeError) as exc:
390393
last_error = exc
391394
if attempt == 0 and retry_safe:
392395
info_path.unlink(missing_ok=True)
393396
continue
394397
break
395-
raise RuntimeError("TypeScript Effect runtime request failed") from last_error
398+
raise EffectRuntimeStartupError(
399+
"TypeScript Effect runtime request failed",
400+
diagnostic_code="runtime_request_failed",
401+
) from last_error
396402

397403

398404
def effect_runtime_result(

‎tests/control_plane/test_start_goal_compact_projection.py‎

Lines changed: 91 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,95 @@ def _invoke_ark_start_goal_cli(
147149
return exit_code, payload
148150

149151

152+
def _block_effect_runtime_startup(monkeypatch: pytest.MonkeyPatch) -> None:
153+
def unavailable(*_args: object, **_kwargs: object) -> object:
154+
raise effect_runtime.EffectRuntimeStartupError(
155+
"LoopX Effect runtime requires Node.js 22.6.0 or newer",
156+
diagnostic_code="node_unavailable",
157+
)
158+
159+
monkeypatch.setattr(
160+
command_pack_module,
161+
"effect_program_from_ordered_steps",
162+
unavailable,
163+
)
164+
165+
166+
def test_cli_reports_effect_runtime_startup_failure_without_traceback(
167+
tmp_path: Path,
168+
monkeypatch: pytest.MonkeyPatch,
169+
) -> None:
170+
project = _write_connected_project(tmp_path)
171+
_block_effect_runtime_startup(monkeypatch)
172+
173+
output = io.StringIO()
174+
with contextlib.redirect_stdout(output):
175+
exit_code = cli_main(
176+
[
177+
"--format",
178+
"json",
179+
"start-goal",
180+
"--guided",
181+
"--project",
182+
str(project),
183+
"--goal-id",
184+
GOAL_ID,
185+
"--agent-id",
186+
AGENT_ID,
187+
"--host-surface",
188+
"shell",
189+
"--goal-text",
190+
GOAL_TEXT,
191+
]
192+
)
193+
194+
raw_output = output.getvalue()
195+
payload = json.loads(raw_output)
196+
assert exit_code == 1
197+
assert payload["ok"] is False
198+
assert payload["schema_version"] == "loopx_start_goal_guided_v0"
199+
assert payload["error"] == "LoopX TypeScript control-plane runtime is unavailable"
200+
assert payload["diagnostic_code"] == "node_unavailable"
201+
assert payload["runtime_requirement"]["minimum_node_version"] == "22.6.0"
202+
assert "loopx doctor --deep" in payload["recommended_action"]
203+
assert "Traceback" not in raw_output
204+
205+
206+
def test_cli_reports_effect_runtime_startup_failure_in_default_markdown(
207+
tmp_path: Path,
208+
monkeypatch: pytest.MonkeyPatch,
209+
) -> None:
210+
project = _write_connected_project(tmp_path)
211+
_block_effect_runtime_startup(monkeypatch)
212+
213+
output = io.StringIO()
214+
with contextlib.redirect_stdout(output):
215+
exit_code = cli_main(
216+
[
217+
"start-goal",
218+
"--guided",
219+
"--project",
220+
str(project),
221+
"--goal-id",
222+
GOAL_ID,
223+
"--agent-id",
224+
AGENT_ID,
225+
"--host-surface",
226+
"shell",
227+
"--goal-text",
228+
GOAL_TEXT,
229+
]
230+
)
231+
232+
raw_output = output.getvalue()
233+
assert exit_code == 1
234+
assert "# Guided Start Goal" in raw_output
235+
assert "LoopX TypeScript control-plane runtime is unavailable" in raw_output
236+
assert "diagnostic_code: `node_unavailable`" in raw_output
237+
assert "loopx doctor --deep" in raw_output
238+
assert "Traceback" not in raw_output
239+
240+
150241
def test_default_projection_preserves_host_actions_and_json_anchors(
151242
tmp_path: Path,
152243
) -> None:

‎tests/control_plane/test_turn_journal_runtime_readiness.py‎

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

5656

57+
def test_missing_node_request_raises_startup_diagnostic(
58+
tmp_path: Path,
59+
monkeypatch: pytest.MonkeyPatch,
60+
) -> None:
61+
monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: tmp_path)
62+
monkeypatch.setattr(effect_runtime.shutil, "which", lambda _name: None)
63+
64+
with pytest.raises(effect_runtime.EffectRuntimeStartupError) as error:
65+
effect_runtime.effect_runtime_result("runtime.ping", {})
66+
67+
assert error.value.diagnostic_code == "node_unavailable"
68+
assert "Node.js 22.6.0 or newer" in str(error.value)
69+
70+
5771
def test_old_node_is_reported_without_running_semantic_probe(
5872
monkeypatch: pytest.MonkeyPatch,
5973
) -> None:

0 commit comments

Comments
 (0)