Commit db62570d35f94d7edd66382b588fe1c304ae2e4b
1 parent
14dd0fd3
manual_resume now actually continues execution for most blocked reasons
Auto-continuation after a successful manual_resume only fired for
blocked_reason in ('user_paused', 'user_stopped'). A real transient network
blip ("API Error: Unable to connect to API (ENOTFOUND)") blocks a task with
blocked_reason='execution_error' the same way any other unroutable
exception does -- resuming it only flipped the status field back, with no
path to actually re-trigger execution (/execute requires status='queued',
but a resumed task lands on 'planning_brd_prd_sdd'/'coding' instead).
Clicking "resume" was a dead end a second click couldn't fix.
The user has already made the judgment call to retry by clicking resume at
all. Withholding continuation only made sense for the two reasons with
their own dedicated resume path requiring a different action first:
budget_exceeded (must raise the limit) and loop_limit_reached (excluded by
name, not just by its usual restored status). Every other blocked_reason
now auto-continues on a successful resume to a resumable status.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
70 additions
and
5 deletions
src/backend_server.py
| @@ -479,10 +479,31 @@ class _Handler(BaseHTTPRequestHandler): | @@ -479,10 +479,31 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 479 | matching what the ORIGINAL blocked_pending_human/manual_resume | 479 | matching what the ORIGINAL blocked_pending_human/manual_resume |
| 480 | design already implies (04-P: "调度器继续执行队列中其他任务" -- | 480 | design already implies (04-P: "调度器继续执行队列中其他任务" -- |
| 481 | resuming should let the scheduler keep going, not just flip a | 481 | resuming should let the scheduler keep going, not just flip a |
| 482 | - status flag and stop). System-triggered blocks (loop_limit_reached, | ||
| 483 | - budget_exceeded) keep their existing behavior unchanged -- only | ||
| 484 | - restoring status, no auto-continuation -- that's a separate, | ||
| 485 | - already-tested concern this change doesn't touch.""" | 482 | + status flag and stop). |
| 483 | + | ||
| 484 | + **2026-08-04 broadened**: found for real -- a transient network | ||
| 485 | + blip (`claude` CLI: "API Error: Unable to connect to API | ||
| 486 | + (ENOTFOUND)") blocks a task with blocked_reason='execution_error', | ||
| 487 | + same as any other unroutable exception (task_execution._mark_blocked's | ||
| 488 | + catch-all). The original 'user_paused'/'user_stopped' allowlist left | ||
| 489 | + this stuck: manual_resume flipped the status back but nothing ever | ||
| 490 | + re-triggered real execution, and /execute's own guard requires | ||
| 491 | + status='queued' (a resumed task lands on 'planning_brd_prd_sdd' or | ||
| 492 | + 'coding' instead) -- clicking "resume" did nothing a second click | ||
| 493 | + could fix. The user has ALREADY made the judgment call to retry by | ||
| 494 | + clicking resume at all; withholding continuation only made sense for | ||
| 495 | + the two reasons that have their OWN dedicated resume path requiring | ||
| 496 | + a different human action first: `budget_exceeded` (must raise the | ||
| 497 | + limit -- see budget_guardrail.update_budget_limit_and_resume/ | ||
| 498 | + upsert_budget_config, which are the actual intended resume trigger) | ||
| 499 | + and `loop_limit_reached` (excluded explicitly by name, not just by | ||
| 500 | + relying on its real from_status usually being 'validating' -- a | ||
| 501 | + wrong-status edge case, real or a test seeding one, shouldn't be | ||
| 502 | + able to accidentally slip a 3-strikes task back into auto-retry). | ||
| 503 | + Every other blocked_reason (execution_error, classifier_blocked, | ||
| 504 | + corrupt_persisted_state, usage_limit_wait_timeout, | ||
| 505 | + prd_consistency_check_failed, user_paused, user_stopped, ...) now | ||
| 506 | + auto-continues on a successful resume to a resumable status.""" | ||
| 486 | payload, error = self._read_json_body() | 507 | payload, error = self._read_json_body() |
| 487 | if error: | 508 | if error: |
| 488 | self._send_json(400, error) | 509 | self._send_json(400, error) |
| @@ -503,7 +524,7 @@ class _Handler(BaseHTTPRequestHandler): | @@ -503,7 +524,7 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 503 | except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e: | 524 | except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e: |
| 504 | self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) | 525 | self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) |
| 505 | return | 526 | return |
| 506 | - if pre_resume_reason in ("user_paused", "user_stopped") and result.get("new_status") in ( | 527 | + if pre_resume_reason not in ("budget_exceeded", "loop_limit_reached") and result.get("new_status") in ( |
| 507 | "queued", "planning_brd_prd_sdd", "coding", | 528 | "queued", "planning_brd_prd_sdd", "coding", |
| 508 | ): | 529 | ): |
| 509 | task_row = self.server.conn.execute( | 530 | task_row = self.server.conn.execute( |
src/test_backend_server.py
| @@ -830,6 +830,50 @@ class TaskExecutionRouteTest(unittest.TestCase): | @@ -830,6 +830,50 @@ class TaskExecutionRouteTest(unittest.TestCase): | ||
| 830 | self.assertEqual(status, 200) | 830 | self.assertEqual(status, 200) |
| 831 | mock_start.assert_not_called() | 831 | mock_start.assert_not_called() |
| 832 | 832 | ||
| 833 | + def test_manual_resume_after_budget_exceeded_does_not_auto_continue(self): | ||
| 834 | + # 2026-08-04: broadening auto-continue to "everything except | ||
| 835 | + # loop_limit_reached/budget_exceeded" must not accidentally sweep | ||
| 836 | + # budget_exceeded back in -- it has its own dedicated resume trigger | ||
| 837 | + # (raising the limit), not a blind retry on the same budget wall. | ||
| 838 | + self.server.conn.execute( | ||
| 839 | + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'budget_exceeded' " | ||
| 840 | + "WHERE task_id = 'task-1'" | ||
| 841 | + ) | ||
| 842 | + self.server.conn.execute( | ||
| 843 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 844 | + "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', " | ||
| 845 | + "'{\"from_status\": \"coding\", \"to_status\": \"blocked_pending_human\"}')" | ||
| 846 | + ) | ||
| 847 | + self.server.conn.commit() | ||
| 848 | + with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start: | ||
| 849 | + status, body = self._post("/api/tasks/task-1/route", {"decided_route": "manual_resume"}) | ||
| 850 | + self.assertEqual(status, 200) | ||
| 851 | + mock_start.assert_not_called() | ||
| 852 | + | ||
| 853 | + def test_manual_resume_after_execution_error_auto_continues(self): | ||
| 854 | + # 2026-08-04 regression: found for real -- a transient network blip | ||
| 855 | + # ("API Error: Unable to connect to API (ENOTFOUND)") blocks a task | ||
| 856 | + # with blocked_reason='execution_error'. Resuming it used to just | ||
| 857 | + # flip the status field back with no way to actually continue | ||
| 858 | + # (/execute requires status='queued', but a resumed task lands on | ||
| 859 | + # 'planning_brd_prd_sdd'/'coding' instead) -- a dead end a second | ||
| 860 | + # click couldn't fix. | ||
| 861 | + self.server.conn.execute( | ||
| 862 | + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'execution_error' " | ||
| 863 | + "WHERE task_id = 'task-1'" | ||
| 864 | + ) | ||
| 865 | + self.server.conn.execute( | ||
| 866 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 867 | + "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', " | ||
| 868 | + "'{\"from_status\": \"planning_brd_prd_sdd\", \"to_status\": \"blocked_pending_human\", " | ||
| 869 | + "\"detail\": \"API Error: Unable to connect to API (ENOTFOUND)\"}')" | ||
| 870 | + ) | ||
| 871 | + self.server.conn.commit() | ||
| 872 | + with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start: | ||
| 873 | + status, body = self._post("/api/tasks/task-1/route", {"decided_route": "manual_resume"}) | ||
| 874 | + self.assertEqual(status, 200) | ||
| 875 | + mock_start.assert_called_once_with(self.server.db_path, "task-1", "/tmp/proj-1", "the goal") | ||
| 876 | + | ||
| 833 | 877 | ||
| 834 | def _ws_handshake_request(path, key): | 878 | def _ws_handshake_request(path, key): |
| 835 | return ( | 879 | return ( |