Skip to content

Commit 3e8e214

Browse files
authored
Merge pull request #138 from WecoAI/dev
Merge Dev - Suggest hang fix + bump version (0.3.28)
2 parents cebb708 + 2d9704a commit 3e8e214

5 files changed

Lines changed: 185 additions & 25 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ name = "weco"
88
authors = [{ name = "Weco AI Team", email = "contact@weco.ai" }]
99
description = "Documentation for `weco`, a CLI for using Weco AI's code optimizer."
1010
readme = "README.md"
11-
version = "0.3.27"
11+
version = "0.3.28"
1212
license = { file = "LICENSE" }
1313
requires-python = ">=3.9"
1414
dependencies = [

weco/api.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
WecoClient,
1818
RunSummary,
1919
ExecutionTasksResult,
20+
format_api_error,
2021
handle_api_error,
2122
_truncate_output,
2223
)
@@ -189,15 +190,24 @@ def submit_execution_result(
189190
task_id: str,
190191
execution_output: str,
191192
auth_headers: dict = {},
192-
timeout: Union[int, Tuple[int, int]] = (10, 3650),
193+
timeout: Optional[Union[int, Tuple[int, int]]] = None,
193194
api_keys: Optional[Dict[str, str]] = None,
194-
) -> Optional[Dict[str, Any]]:
195-
"""Submit execution result for a task."""
195+
) -> Dict[str, Any]:
196+
"""Submit execution result for a task.
197+
198+
Args:
199+
timeout: Optional override for the HTTP ``(connect, read)`` timeout.
200+
``None`` keeps the existing default of ``(10, 3650)`` so callers
201+
that don't opt in see no behavior change.
202+
203+
Raises:
204+
requests.exceptions.HTTPError: On non-2xx responses (e.g. 402 insufficient
205+
credits, 503 candidate generation failed). Callers should format the
206+
error via :func:`format_api_error` and surface it through the UI.
207+
requests.exceptions.RequestException: On network errors.
208+
"""
196209
client = WecoClient(auth_headers)
197-
try:
198-
return client.suggest(run_id, execution_output=execution_output, task_id=task_id, api_keys=api_keys)
199-
except Exception:
200-
return None
210+
return client.suggest(run_id, execution_output=execution_output, task_id=task_id, api_keys=api_keys, timeout=timeout)
201211

202212

203213
# --- Share API Functions ---

weco/cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def configure_run_parser(run_parser: argparse.ArgumentParser) -> None:
164164
default="rich",
165165
help="Output mode: 'rich' for interactive terminal UI (default), 'plain' for machine-readable text output suitable for LLM agents.",
166166
)
167+
run_parser.add_argument("--submit-timeout", type=int, default=None, help=argparse.SUPPRESS)
167168

168169
# --- Eval backend integration ---
169170
run_parser.add_argument(
@@ -344,6 +345,7 @@ def configure_resume_parser(resume_parser: argparse.ArgumentParser) -> None:
344345
default="rich",
345346
help="Output mode: 'rich' for interactive terminal UI (default), 'plain' for machine-readable text output suitable for LLM agents.",
346347
)
348+
resume_parser.add_argument("--submit-timeout", type=int, default=None, help=argparse.SUPPRESS)
347349

348350

349351
def _dispatch_run_subcommand(sub: str, args: argparse.Namespace) -> None:
@@ -480,6 +482,7 @@ def execute_run_command(args: argparse.Namespace) -> None:
480482
apply_change=args.apply_change,
481483
require_review=args.require_review,
482484
output_mode=args.output,
485+
submit_timeout=getattr(args, "submit_timeout", None),
483486
)
484487

485488
exit_code = 0 if success else 1
@@ -497,7 +500,11 @@ def execute_resume_command(args: argparse.Namespace) -> None:
497500
sys.exit(1)
498501

499502
success = resume_optimization(
500-
run_id=args.run_id, api_keys=api_keys, apply_change=args.apply_change, output_mode=args.output
503+
run_id=args.run_id,
504+
api_keys=api_keys,
505+
apply_change=args.apply_change,
506+
output_mode=args.output,
507+
submit_timeout=getattr(args, "submit_timeout", None),
501508
)
502509

503510
sys.exit(0 if success else 1)

weco/core/api.py

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,50 @@ def _truncate_output(output: str) -> str:
3131
return f"{first}\n ... [{truncated_len} characters truncated] ... \n{last}"
3232

3333

34+
def format_api_error(e: requests.exceptions.HTTPError) -> str:
35+
"""Extract API error details as a plain multi-line string.
36+
37+
Mirrors :func:`handle_api_error` but returns text instead of printing,
38+
so it can be passed to UI handlers (e.g. ``ui.on_error``) that don't
39+
expose a Rich console — the Rich Live panel and the plain-text UI both
40+
consume errors as plain strings via the ``on_error`` protocol.
41+
"""
42+
status = getattr(e.response, "status_code", None)
43+
try:
44+
payload = e.response.json()
45+
detail = payload.get("detail", payload)
46+
except (ValueError, AttributeError):
47+
return getattr(e.response, "text", "") or f"HTTP {status} Error"
48+
49+
def _format(detail_obj: Any) -> list[str]:
50+
if isinstance(detail_obj, str):
51+
return [detail_obj]
52+
if isinstance(detail_obj, dict):
53+
lines: list[str] = []
54+
message_keys = ("message", "error", "msg", "detail")
55+
message = next((detail_obj.get(key) for key in message_keys if detail_obj.get(key)), None)
56+
lines.append(message or f"HTTP {status} Error")
57+
suggestion = detail_obj.get("suggestion")
58+
if suggestion:
59+
lines.append(str(suggestion))
60+
extras = {
61+
k: v
62+
for k, v in detail_obj.items()
63+
if k not in {"message", "error", "msg", "detail", "suggestion"} and v not in (None, "")
64+
}
65+
for key, value in extras.items():
66+
lines.append(f"{key}: {value}")
67+
return lines
68+
if isinstance(detail_obj, list) and detail_obj:
69+
lines = list(_format(detail_obj[0]))
70+
for extra in detail_obj[1:]:
71+
lines.append(str(extra))
72+
return lines
73+
return [str(detail_obj) if detail_obj else f"HTTP {status} Error"]
74+
75+
return "\n".join(_format(detail))
76+
77+
3478
def handle_api_error(e: requests.exceptions.HTTPError, console) -> None:
3579
"""Extract and display error messages from API responses in a structured format."""
3680
status = getattr(e.response, "status_code", None)
@@ -272,11 +316,20 @@ def suggest(
272316
step: int | None = None,
273317
task_id: str | None = None,
274318
api_keys: dict[str, str] | None = None,
319+
timeout: tuple[int, int] | int | None = None,
275320
) -> dict:
276321
"""``POST /runs/{run_id}/suggest`` — submit execution output, get next candidate.
277322
278-
If *step* is provided, transport errors (ReadTimeout, 502, ConnectionError)
279-
trigger an automatic recovery attempt via ``get_run_status``.
323+
If *step* is provided (legacy flow), transport errors (ReadTimeout, 502,
324+
ConnectionError) trigger recovery via ``get_run_status``. If *task_id* is
325+
provided (queue flow), recovery instead checks ``/execution-tasks/`` and
326+
the run status, so a dropped response doesn't hang the CLI for up to
327+
``timeout[1]`` seconds waiting on a socket the backend has already replied on.
328+
329+
Args:
330+
timeout: Optional ``(connect, read)`` tuple or int override for the
331+
HTTP request. Defaults to ``(10, 3650)`` to preserve existing
332+
behavior; pass a smaller value to exercise the recovery path.
280333
281334
Raises:
282335
requests.exceptions.HTTPError: On non-recoverable HTTP errors.
@@ -289,8 +342,10 @@ def suggest(
289342
if api_keys:
290343
body["api_keys"] = api_keys
291344

345+
request_timeout = timeout if timeout is not None else (10, 3650)
346+
292347
try:
293-
resp = self._post(f"/runs/{run_id}/suggest", json=body, timeout=(10, 3650))
348+
resp = self._post(f"/runs/{run_id}/suggest", json=body, timeout=request_timeout)
294349
resp.raise_for_status()
295350
result = resp.json()
296351
if result.get("plan") is None:
@@ -303,12 +358,21 @@ def suggest(
303358
recovered = self._recover_suggest(run_id, step)
304359
if recovered is not None:
305360
return recovered
361+
elif task_id is not None:
362+
recovered = self._recover_queue_suggest(run_id)
363+
if recovered is not None:
364+
return recovered
306365
raise type(exc)(exc) from exc
307366
except requests.exceptions.HTTPError as exc:
308-
if step is not None and getattr(exc.response, "status_code", None) == 502:
367+
status_code = getattr(exc.response, "status_code", None)
368+
if step is not None and status_code == 502:
309369
recovered = self._recover_suggest(run_id, step)
310370
if recovered is not None:
311371
return recovered
372+
elif task_id is not None and status_code in (502, 503, 504):
373+
recovered = self._recover_queue_suggest(run_id)
374+
if recovered is not None:
375+
return recovered
312376
raise
313377

314378
def heartbeat(self, run_id: str) -> bool:
@@ -482,6 +546,64 @@ def log_external_step(
482546
# Internal
483547
# ------------------------------------------------------------------
484548

549+
def _recover_queue_suggest(self, run_id: str) -> dict | None:
550+
"""Try to reconstruct a ``/suggest`` response for queue-mode clients.
551+
552+
Called after a transport error (ReadTimeout / 5xx / ConnectionError) when
553+
a ``task_id`` was supplied. The backend marks the submitted task as
554+
completed early in ``/suggest`` and, if everything succeeds, atomically
555+
creates the next node + revision + execution task before returning.
556+
557+
If we can observe either (a) a ready execution task queued for this run,
558+
or (b) the run transitioning to ``completed``, the submit effectively
559+
landed — we can synthesize a success response and let the main loop
560+
proceed to its next poll/claim iteration. Also recover the previous
561+
step's metric from the run history so the UI's ``on_metric`` still fires
562+
for the step whose response we missed. Otherwise return ``None`` and let
563+
the caller surface the transport error.
564+
"""
565+
try:
566+
run_data = self.get_run_status(run_id, include_history=True)
567+
except Exception:
568+
run_data = None
569+
570+
run_status = (run_data or {}).get("status")
571+
if run_status in ("terminated", "error"):
572+
return None
573+
574+
# Latest node that has an execution output is the one we just evaluated
575+
# — its metric is the ``previous_solution_metric_value`` the caller expects.
576+
previous_metric = None
577+
if run_data is not None:
578+
evaluated_nodes = [
579+
n
580+
for n in (run_data.get("nodes") or [])
581+
if n.get("execution_output") is not None and n.get("metric_value") is not None
582+
]
583+
if evaluated_nodes:
584+
latest_evaluated = max(evaluated_nodes, key=lambda n: n.get("step", 0))
585+
previous_metric = latest_evaluated.get("metric_value")
586+
587+
def _with_metric(payload: dict) -> dict:
588+
if previous_metric is not None:
589+
payload["previous_solution_metric_value"] = previous_metric
590+
return payload
591+
592+
if run_status == "completed":
593+
return _with_metric({"run_id": run_id, "is_done": True})
594+
595+
# Verify the next candidate task is queued — that's the signal that the
596+
# submit landed end-to-end (not just that the previous node was updated).
597+
try:
598+
tasks_result = self.get_execution_tasks(run_id)
599+
except Exception:
600+
tasks_result = None
601+
602+
if tasks_result is not None and tasks_result.tasks:
603+
return _with_metric({"run_id": run_id, "is_done": False})
604+
605+
return None
606+
485607
def _recover_suggest(self, run_id: str, step: int) -> dict | None:
486608
"""Try to reconstruct a ``/suggest`` response after a transport error.
487609

weco/optimizer.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
from dataclasses import dataclass
88
from typing import Optional
99

10+
from requests.exceptions import HTTPError
1011
from rich.console import Console
1112
from rich.prompt import Confirm
1213

1314
from . import __dashboard_url__
1415
from .api import (
1516
claim_execution_task,
17+
format_api_error,
1618
get_execution_tasks,
1719
get_optimization_run_status,
1820
report_termination,
@@ -82,6 +84,7 @@ def _run_optimization_loop(
8284
poll_interval: float = 2.0,
8385
max_poll_attempts: int = 300,
8486
api_keys: Optional[dict] = None,
87+
submit_timeout: Optional[int] = None,
8588
) -> OptimizationResult:
8689
"""
8790
Shared queue-based execution loop for optimize and resume.
@@ -103,6 +106,9 @@ def _run_optimization_loop(
103106
poll_interval: Seconds between polling attempts.
104107
max_poll_attempts: Max polls before timeout (~10 min with 2s interval).
105108
api_keys: Optional API keys for LLM providers.
109+
submit_timeout: Optional read-timeout override (seconds) for the
110+
``/suggest`` call made when submitting a step's result. ``None``
111+
preserves the existing ~61-minute default.
106112
107113
Returns:
108114
OptimizationResult with success status and termination info.
@@ -189,22 +195,20 @@ def _run_optimization_loop(
189195

190196
ui.on_output(term_out)
191197

192-
# Submit result
198+
# Submit result. HTTP errors (insufficient credits, candidate generation
199+
# failures, etc.) propagate and are handled centrally below so the real
200+
# backend detail reaches the user and the run's termination record.
193201
ui.on_submitting()
202+
submit_timeout_tuple = (10, submit_timeout) if submit_timeout is not None else None
194203
result = submit_execution_result(
195-
run_id=run_id, task_id=task_id, execution_output=term_out, auth_headers=auth_headers, api_keys=api_keys
204+
run_id=run_id,
205+
task_id=task_id,
206+
execution_output=term_out,
207+
auth_headers=auth_headers,
208+
api_keys=api_keys,
209+
timeout=submit_timeout_tuple,
196210
)
197211

198-
if result is None:
199-
ui.on_error("Failed to submit result")
200-
return OptimizationResult(
201-
success=False,
202-
final_step=step,
203-
status="error",
204-
reason="submit_failed",
205-
details="Failed to submit execution result",
206-
)
207-
208212
is_done = result.get("is_done", False)
209213
prev_metric = result.get("previous_solution_metric_value")
210214

@@ -220,6 +224,19 @@ def _run_optimization_loop(
220224
except KeyboardInterrupt:
221225
ui.on_interrupted()
222226
return OptimizationResult(success=False, final_step=step, status="terminated", reason="user_terminated_sigint")
227+
except HTTPError as e:
228+
# Surface structured API error details (insufficient credits, auth failures, candidate
229+
# generation failures, etc.) through the UI rather than a generic exception string.
230+
error_message = format_api_error(e)
231+
ui.on_error(error_message)
232+
status_code = getattr(e.response, "status_code", None)
233+
return OptimizationResult(
234+
success=False,
235+
final_step=step,
236+
status="error",
237+
reason=f"http_{status_code}" if status_code else "http_error",
238+
details=error_message,
239+
)
223240
except Exception as e:
224241
ui.on_error(f"Error: {e}")
225242
return OptimizationResult(success=False, final_step=step, status="error", reason="unknown", details=str(e))
@@ -300,6 +317,7 @@ def resume_optimization(
300317
poll_interval: float = 2.0,
301318
apply_change: bool = False,
302319
output_mode: str = "rich",
320+
submit_timeout: Optional[int] = None,
303321
) -> bool:
304322
"""
305323
Resume an interrupted run using the queue-based optimization loop.
@@ -444,6 +462,7 @@ def resume_optimization(
444462
start_step=current_step,
445463
poll_interval=poll_interval,
446464
api_keys=api_keys,
465+
submit_timeout=submit_timeout,
447466
)
448467

449468
# Stop heartbeat immediately after loop completes
@@ -503,6 +522,7 @@ def optimize(
503522
apply_change: bool = False,
504523
require_review: bool = False,
505524
output_mode: str = "rich",
525+
submit_timeout: Optional[int] = None,
506526
) -> bool:
507527
"""
508528
Simplified queue-based optimization loop.
@@ -628,6 +648,7 @@ def optimize(
628648
start_step=0,
629649
poll_interval=poll_interval,
630650
api_keys=api_keys,
651+
submit_timeout=submit_timeout,
631652
)
632653

633654
# Stop heartbeat immediately after loop completes

0 commit comments

Comments
 (0)