Skip to content

Commit 11dd390

Browse files
committed
task: make progress estimate parsing non-fatal
1 parent 9529fbe commit 11dd390

4 files changed

Lines changed: 157 additions & 52 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "codexapi"
7-
version = "0.6.7"
7+
version = "0.6.8"
88
description = "Minimal Python API for running the Codex CLI."
99
readme = "README.md"
1010
requires-python = ">=3.8"

src/codexapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@
2727
"task_result",
2828
"watch",
2929
]
30-
__version__ = "0.6.7"
30+
__version__ = "0.6.8"

src/codexapi/task.py

Lines changed: 81 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,27 @@ def failure_prompt(self, error):
486486
"""Ask the agent to summarize remaining issues after retries."""
487487
return _failure_prompt(error)
488488

489+
def _estimate_progress(self, agent_output, check_output):
490+
"""Run a progress estimate and return parsed data or an error string."""
491+
try:
492+
return (
493+
estimate(
494+
self.prompt,
495+
agent_output or "",
496+
check_output or "",
497+
self.cwd,
498+
self._yolo,
499+
self._flags,
500+
self._progress_total,
501+
),
502+
None,
503+
)
504+
except Exception as exc:
505+
error = _single_line(str(exc))
506+
if not error:
507+
error = exc.__class__.__name__
508+
return None, error
509+
489510
def __call__(self, debug=False, progress=False):
490511
"""Run the task with checker-driven retries.
491512
If debug is True, log debug messages.
@@ -499,29 +520,24 @@ def __call__(self, debug=False, progress=False):
499520

500521
progress_updates = progress or self._progress_updates
501522
self._progress_enabled = progress
523+
start_time = time.monotonic()
524+
self._progress_start = start_time
502525
if progress_updates:
503-
remaining, _summary = estimate(
504-
self.prompt,
505-
"",
506-
"",
507-
self.cwd,
508-
self._yolo,
509-
self._flags,
510-
None,
511-
)
512-
self._progress_total = remaining
513-
start_time = time.monotonic()
514-
self._progress_start = start_time
515-
self.on_progress(
516-
0,
517-
self.max_iterations,
518-
self._progress_total,
519-
remaining,
520-
None,
521-
)
522-
else:
523-
start_time = time.monotonic()
524-
self._progress_start = start_time
526+
estimate_result, estimate_error = self._estimate_progress("", "")
527+
if estimate_result is not None:
528+
remaining, _summary = estimate_result
529+
self._progress_total = remaining
530+
self.on_progress(
531+
0,
532+
self.max_iterations,
533+
self._progress_total,
534+
remaining,
535+
None,
536+
)
537+
elif debug:
538+
_logger.debug(
539+
"Skipping initial progress update: %s", estimate_error
540+
)
525541

526542
# Start with the initial prompt
527543
output = self.agent(self.prompt)
@@ -541,37 +557,52 @@ def __call__(self, debug=False, progress=False):
541557
check_output = self.last_check_output
542558
if self.check_skipped:
543559
check_output = "Verification skipped."
544-
remaining, summary = estimate(
545-
self.prompt,
560+
progress_data = None
561+
estimate_result, estimate_error = self._estimate_progress(
546562
self.last_output or "",
547563
check_output or "",
548-
self.cwd,
549-
self._yolo,
550-
self._flags,
551-
self._progress_total,
552-
)
553-
total_estimate = self._progress_total
554-
if total_estimate is None or remaining > total_estimate:
555-
total_estimate = remaining
556-
self._progress_total = total_estimate
557-
elapsed = _format_elapsed(time.monotonic() - start_time)
558-
status_prefix = (
559-
f"[{_format_turns(iteration, self.max_iterations)} @ {elapsed}]"
560-
)
561-
is_final = not error or (
562-
self.max_iterations and iteration >= self.max_iterations
563-
)
564-
if is_final:
565-
marker = "✅" if not error else "❌"
566-
summary = f"{marker} {summary}".strip()
567-
status_line = f"{status_prefix}: {summary}".rstrip()
568-
self.on_progress(
569-
iteration,
570-
self.max_iterations,
571-
total_estimate,
572-
remaining,
573-
status_line,
574564
)
565+
if estimate_result is not None:
566+
remaining, summary = estimate_result
567+
total_estimate = self._progress_total
568+
if total_estimate is None or remaining > total_estimate:
569+
total_estimate = remaining
570+
self._progress_total = total_estimate
571+
progress_data = (total_estimate, remaining, summary)
572+
else:
573+
total_estimate = self._progress_total
574+
if total_estimate is None:
575+
if debug:
576+
_logger.debug(
577+
"Skipping progress update: %s", estimate_error
578+
)
579+
else:
580+
summary = f"Progress estimate unavailable: {estimate_error}"
581+
progress_data = (
582+
total_estimate,
583+
total_estimate,
584+
summary,
585+
)
586+
if progress_data is not None:
587+
total_estimate, remaining, summary = progress_data
588+
elapsed = _format_elapsed(time.monotonic() - start_time)
589+
status_prefix = (
590+
f"[{_format_turns(iteration, self.max_iterations)} @ {elapsed}]"
591+
)
592+
is_final = not error or (
593+
self.max_iterations and iteration >= self.max_iterations
594+
)
595+
if is_final:
596+
marker = "✅" if not error else "❌"
597+
summary = f"{marker} {summary}".strip()
598+
status_line = f"{status_prefix}: {summary}".rstrip()
599+
self.on_progress(
600+
iteration,
601+
self.max_iterations,
602+
total_estimate,
603+
remaining,
604+
status_line,
605+
)
575606
if not error:
576607
summary = self.agent(self.success_prompt())
577608
if debug:

tests/test_task_progress.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import sys
2+
import unittest
3+
from pathlib import Path
4+
from unittest.mock import patch
5+
6+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
7+
8+
from codexapi.task import Task
9+
10+
11+
class _FakeAgent:
12+
def __init__(self):
13+
self.thread_id = "thread-123"
14+
15+
def __call__(self, prompt):
16+
return "ok"
17+
18+
19+
class _FakePushover:
20+
def ensure_ready(self, announce=True):
21+
return False
22+
23+
def send(self, title, message):
24+
return False
25+
26+
27+
class _ImmediateSuccessTask(Task):
28+
def __init__(self):
29+
super().__init__("do the thing", max_iterations=3)
30+
self.agent = _FakeAgent()
31+
self._pushover = _FakePushover()
32+
self.set_up_called = False
33+
self.tear_down_called = False
34+
35+
def set_up(self):
36+
self.set_up_called = True
37+
38+
def tear_down(self):
39+
self.tear_down_called = True
40+
41+
def check(self, output=None):
42+
self.last_check_output = '{"success": true, "reason": "ok"}'
43+
self.check_skipped = False
44+
return None
45+
46+
def notify_pushover(self, result):
47+
return None
48+
49+
50+
class TaskProgressEstimateFailureTests(unittest.TestCase):
51+
def test_progress_does_not_crash_when_initial_estimate_fails(self):
52+
task = _ImmediateSuccessTask()
53+
with patch("codexapi.task.estimate", side_effect=RuntimeError("bad json")):
54+
result = task(progress=True)
55+
self.assertTrue(result.success)
56+
self.assertEqual(result.iterations, 1)
57+
self.assertTrue(task.set_up_called)
58+
self.assertTrue(task.tear_down_called)
59+
60+
def test_progress_does_not_crash_when_later_estimate_fails(self):
61+
task = _ImmediateSuccessTask()
62+
with patch(
63+
"codexapi.task.estimate",
64+
side_effect=[(5, "initial"), RuntimeError("bad json")],
65+
):
66+
result = task(progress=True)
67+
self.assertTrue(result.success)
68+
self.assertEqual(result.iterations, 1)
69+
self.assertTrue(task.set_up_called)
70+
self.assertTrue(task.tear_down_called)
71+
72+
73+
if __name__ == "__main__":
74+
unittest.main()

0 commit comments

Comments
 (0)