From db62570d35f94d7edd66382b588fe1c304ae2e4b Mon Sep 17 00:00:00 2001 From: bboonnee Date: Tue, 4 Aug 2026 15:24:33 +0800 Subject: [PATCH] manual_resume now actually continues execution for most blocked reasons --- src/backend_server.py | 31 ++++++++++++++++++++++++++----- src/test_backend_server.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/backend_server.py b/src/backend_server.py index 5effc80..8716596 100644 --- a/src/backend_server.py +++ b/src/backend_server.py @@ -479,10 +479,31 @@ class _Handler(BaseHTTPRequestHandler): matching what the ORIGINAL blocked_pending_human/manual_resume design already implies (04-P: "调度器继续执行队列中其他任务" -- resuming should let the scheduler keep going, not just flip a - status flag and stop). System-triggered blocks (loop_limit_reached, - budget_exceeded) keep their existing behavior unchanged -- only - restoring status, no auto-continuation -- that's a separate, - already-tested concern this change doesn't touch.""" + status flag and stop). + + **2026-08-04 broadened**: found for real -- a transient network + blip (`claude` CLI: "API Error: Unable to connect to API + (ENOTFOUND)") blocks a task with blocked_reason='execution_error', + same as any other unroutable exception (task_execution._mark_blocked's + catch-all). The original 'user_paused'/'user_stopped' allowlist left + this stuck: manual_resume flipped the status back but nothing ever + re-triggered real execution, and /execute's own guard requires + status='queued' (a resumed task lands on 'planning_brd_prd_sdd' or + 'coding' instead) -- clicking "resume" did nothing a second click + could 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 that have their OWN dedicated resume path requiring + a different human action first: `budget_exceeded` (must raise the + limit -- see budget_guardrail.update_budget_limit_and_resume/ + upsert_budget_config, which are the actual intended resume trigger) + and `loop_limit_reached` (excluded explicitly by name, not just by + relying on its real from_status usually being 'validating' -- a + wrong-status edge case, real or a test seeding one, shouldn't be + able to accidentally slip a 3-strikes task back into auto-retry). + Every other blocked_reason (execution_error, classifier_blocked, + corrupt_persisted_state, usage_limit_wait_timeout, + prd_consistency_check_failed, user_paused, user_stopped, ...) now + auto-continues on a successful resume to a resumable status.""" payload, error = self._read_json_body() if error: self._send_json(400, error) @@ -503,7 +524,7 @@ class _Handler(BaseHTTPRequestHandler): except (org_loop_route.TaskNotBlockedError, org_loop_route.TaskNotValidatingError, ValueError) as e: self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) return - if pre_resume_reason in ("user_paused", "user_stopped") and result.get("new_status") in ( + if pre_resume_reason not in ("budget_exceeded", "loop_limit_reached") and result.get("new_status") in ( "queued", "planning_brd_prd_sdd", "coding", ): task_row = self.server.conn.execute( diff --git a/src/test_backend_server.py b/src/test_backend_server.py index 1cdff49..daad261 100644 --- a/src/test_backend_server.py +++ b/src/test_backend_server.py @@ -830,6 +830,50 @@ class TaskExecutionRouteTest(unittest.TestCase): self.assertEqual(status, 200) mock_start.assert_not_called() + def test_manual_resume_after_budget_exceeded_does_not_auto_continue(self): + # 2026-08-04: broadening auto-continue to "everything except + # loop_limit_reached/budget_exceeded" must not accidentally sweep + # budget_exceeded back in -- it has its own dedicated resume trigger + # (raising the limit), not a blind retry on the same budget wall. + self.server.conn.execute( + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'budget_exceeded' " + "WHERE task_id = 'task-1'" + ) + self.server.conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', " + "'{\"from_status\": \"coding\", \"to_status\": \"blocked_pending_human\"}')" + ) + self.server.conn.commit() + with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start: + status, body = self._post("/api/tasks/task-1/route", {"decided_route": "manual_resume"}) + self.assertEqual(status, 200) + mock_start.assert_not_called() + + def test_manual_resume_after_execution_error_auto_continues(self): + # 2026-08-04 regression: found for real -- a transient network blip + # ("API Error: Unable to connect to API (ENOTFOUND)") blocks a task + # with blocked_reason='execution_error'. Resuming it used to just + # flip the status field back with no way to actually continue + # (/execute requires status='queued', but a resumed task lands on + # 'planning_brd_prd_sdd'/'coding' instead) -- a dead end a second + # click couldn't fix. + self.server.conn.execute( + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'execution_error' " + "WHERE task_id = 'task-1'" + ) + self.server.conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES ('evt-1', 'proj-1', 'task-1', 'task_status_changed', " + "'{\"from_status\": \"planning_brd_prd_sdd\", \"to_status\": \"blocked_pending_human\", " + "\"detail\": \"API Error: Unable to connect to API (ENOTFOUND)\"}')" + ) + self.server.conn.commit() + with patch.object(backend_server.task_execution, "start_task_execution_in_background") as mock_start: + status, body = self._post("/api/tasks/task-1/route", {"decided_route": "manual_resume"}) + self.assertEqual(status, 200) + mock_start.assert_called_once_with(self.server.db_path, "task-1", "/tmp/proj-1", "the goal") + def _ws_handshake_request(path, key): return ( -- libgit2 0.21.4