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 479 matching what the ORIGINAL blocked_pending_human/manual_resume
480 480 design already implies (04-P: "调度器继续执行队列中其他任务" --
481 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 507 payload, error = self._read_json_body()
487 508 if error:
488 509 self._send_json(400, error)
... ... @@ -503,7 +524,7 @@ class _Handler(BaseHTTPRequestHandler):
503 524 except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e:
504 525 self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)})
505 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 528 "queued", "planning_brd_prd_sdd", "coding",
508 529 ):
509 530 task_row = self.server.conn.execute(
... ...
src/test_backend_server.py
... ... @@ -830,6 +830,50 @@ class TaskExecutionRouteTest(unittest.TestCase):
830 830 self.assertEqual(status, 200)
831 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 878 def _ws_handshake_request(path, key):
835 879 return (
... ...