Commit db62570d35f94d7edd66382b588fe1c304ae2e4b

Authored by bboonnee
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>
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 (