ソースを参照

fix(executor): stop-simulation button now actually cancels the running task

- PlanDetail.vue stopSimulation was a frontend stub that never called the
  backend; the old 100-point task kept running, so the progress banner
  (task total_points snapshot) diverged from the plan card (live estimate)
  after the user edited scan variables
- Frontend: call POST /api/tasks/{task_id}/cancel, then refresh active-task
- Executor: new _is_task_cancelled() polls GET /api/tasks/{id} between
  points (best-effort, network errors never abort); cancelled tasks break
  the loop and report partial results as cancelled
- Tests: new scripts/test_executor_cancel.py (6 cases); regression
  m3+p5m2+config 32 cases all pass; vue-tsc clean
carlin 17 時間 前
コミット
3342d3ce32

+ 12 - 0
docs/CONVERSATION_LOG.md

@@ -1140,3 +1140,15 @@ ambient_temperature=25.0)` 单点端到端 PASS——7 项热指标落盘,温
 - force push 成功:远端 master 从 de7761e 更新到本地重建历史(6 个提交,325 对象,77.68 MiB)。
 - **已知限制**:git ls-remote/fetch(upload-pack 方向)在该环境仍会超时;push(receive-pack)正常。
   如需 fetch,用 `-c credential.helper=` 绕过或后续排查 Gogs upload-pack。
+
+## 2026-09-04 — 停止仿真失效导致进度条点数不更新(三层根因修复)
+
+**问题**:用户停仿真→改扫描变量→进度条仍显示旧任务 100 点/3.8 小时,与方案 5 点/11 分钟不符。
+**根因**:① PlanDetail stopSimulation 是前端桩从未调 cancel API;② 后端 cancel 只改 DB,
+执行器点循环不回查 Web 任务状态,旧任务照跑;③ 进度条(任务快照点数)与方案卡片
+(实时重算点数)口径不同,旧任务不死必分叉。
+**修复**:前端接 `POST /api/tasks/{id}/cancel`;执行器新增 `_is_task_cancelled()` 点间回查
+(best-effort,网络异常不中断),取消后中断循环并上报部分结果为 cancelled。
+**测试**:TEST-063,新增 test_executor_cancel.py 6 项 + 回归 32 项全过;vue-tsc 0 错误。
+**遗留**:多执行器场景下 dispatched 任务可能被另一实例重复领取(claim 守卫仅挡 pending),
+为既有缺口,本次未动。

+ 16 - 0
docs/TEST_RECORDS.md

@@ -2247,3 +2247,19 @@ solver.run_single_point(thermal_mode)(coupled 分支调 do_magnetic_thermal_ca
 
 任务级三档热仿真开关全链路打通并实测通过。工程师创建任务时可选:
 仅电磁(128s/点)/ 电磁+稳态热(默认,134s/点)/ 磁热耦合(精算,474s/点)。
+
+---
+
+## TEST-063 — 停止仿真按钮失效修复(桩函数 + 执行器取消回查)
+
+- **日期**:2026-09-04
+- **问题**:用户点"停止仿真"后修改扫描变量,进度条仍显示旧任务的 0/100 点、预计 3.8 小时,与方案卡片的 5 点/11 分钟不一致
+- **根因(三层叠加)**:
+  1. `PlanDetail.vue` `stopSimulation` 是前端桩(注释 "API call to stop would go here"),从未调用后端 cancel,旧任务继续跑
+  2. 后端 `POST /api/tasks/{id}/cancel` 只改 DB 状态;执行器点循环只查进程内 `_stop_event`,从不回查 Web 端任务状态
+  3. 进度条用任务创建快照 `activeTask.total_points`(100),方案卡片用实时重算 `estimatedPoints`(5),旧任务不死则必分叉
+- **修复**:
+  1. 前端 `stopSimulation` 真正调 `POST /api/tasks/{task_id}/cancel` 并刷新 `loadActiveTask`
+  2. 执行器新增 `_is_task_cancelled()`:每点前 best-effort 查 `GET /api/tasks/{id}`(网络失败不误判中断),cancelled 则设 `_task_cancelled` 中断循环,按 `cancelled` 上报已完成点
+- **测试**:新增 `scripts/test_executor_cancel.py`(6 项全过:本地模式不取消/网络异常不误判/cancelled 检出/running 不误判/中途取消只跑 1 点并上报 cancelled/未取消跑全部点);回归 test_executor_m3 4 项、test_executor_p5m2 8 项、test_executor_config 20 项全过;前端 vue-tsc 0 错误
+- **注意**:取消在点边界生效(当前点跑完后中断),已完成的点保留上报

+ 35 - 1
scripts/task_executor.py

@@ -52,6 +52,9 @@ class TaskExecutor:
         self._running = False
         self._current_task: Optional[Dict[str, Any]] = None
         self._stop_event = threading.Event()
+        # Set when the web side cancelled the currently-running task
+        # (detected by polling GET /api/tasks/{id} between points).
+        self._task_cancelled = False
         if executor_id is not None:
             self.executor_id = executor_id
         else:
@@ -201,6 +204,26 @@ class TaskExecutor:
                 self.on_error(f"Report progress failed: {str(e)}")
             return False
 
+    def _is_task_cancelled(self, task_id: str) -> bool:
+        """Best-effort check whether the web side cancelled this task.
+
+        Polls GET /api/tasks/{task_id} once per point. Network or backend
+        failures return False on purpose: a transient error must never abort
+        a running simulation. Local mode (no requests) cannot be cancelled
+        remotely, so it always returns False.
+        """
+        if requests is None or not self.web_base_url:
+            return False
+        try:
+            resp = requests.get(
+                f"{self.web_base_url}/api/tasks/{task_id}", timeout=5
+            )
+            if resp.status_code == 200:
+                return resp.json().get("status") == "cancelled"
+        except Exception:
+            pass
+        return False
+
     def report_results(
         self,
         task_id: str,
@@ -291,6 +314,7 @@ class TaskExecutor:
         start_time = time.time()
 
         self._current_task = task
+        self._task_cancelled = False
         # P6: task-level thermal mode switch (off/steady/coupled). Read once
         # per task; every point uses it via _run_simulation_point.
         self._current_thermal_mode = task.get("thermal_mode")
@@ -314,6 +338,16 @@ class TaskExecutor:
             if self._stop_event.is_set():
                 break
 
+            # Web-side cancellation: the user clicked stop on the UI, which
+            # flips the task status to 'cancelled'. Abort between points so a
+            # cancelled task does not keep consuming Motor-CAD time; points
+            # already finished are still reported below.
+            if self._is_task_cancelled(task_id):
+                self._task_cancelled = True
+                if self.on_progress:
+                    self.on_progress(task_id, idx, total_points)
+                break
+
             elapsed = time.time() - start_time
             self.report_progress(task_id, idx, total_points, params, elapsed)
 
@@ -341,7 +375,7 @@ class TaskExecutor:
         duration = time.time() - start_time
         metrics = self._compute_metrics(results)
         # Status reflects actual outcome: completed/cancelled/failed
-        if self._stop_event.is_set():
+        if self._stop_event.is_set() or self._task_cancelled:
             status = "cancelled"
         elif any(r.get("status") == "FAILED" for r in results):
             status = "completed_with_errors" if any(

+ 158 - 0
scripts/test_executor_cancel.py

@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+# -*- coding: ascii -*-
+"""Tests for web-side task cancellation in the executor point loop.
+
+Bug context (2026-09-04): the PlanDetail stop button never called the
+backend cancel API, and the executor never polled task status between
+points, so a "stopped" scan kept consuming Motor-CAD time. These tests
+cover the fix:
+  1. _is_task_cancelled returns False without requests (local mode).
+  2. _is_task_cancelled returns False on network/backend errors.
+  3. execute_task aborts between points when the web reports 'cancelled'
+     and reports the partial results with status 'cancelled'.
+
+Note: the module-level `requests` in task_executor may be None in this
+dev environment (requests not installed), so tests patch the module
+attribute itself rather than requests.get.
+
+Run: python scripts/test_executor_cancel.py   (exit 0 = PASS)
+"""
+import os
+import sys
+import tempfile
+import unittest
+from unittest.mock import MagicMock, patch
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+import task_executor  # noqa: E402
+
+
+class FakeResponse:
+    def __init__(self, status_code=200, payload=None):
+        self.status_code = status_code
+        self._payload = payload or {}
+
+    def json(self):
+        return self._payload
+
+
+def make_executor(**kwargs):
+    kwargs.setdefault("web_base_url", "http://web.test")
+    kwargs.setdefault("enable_mock", True)
+    kwargs.setdefault("task_dir", tempfile.mkdtemp())
+    return task_executor.TaskExecutor(**kwargs)
+
+
+def make_task(n_points=5):
+    return {
+        "task_id": "t-cancel-1",
+        "task_name": "cancel-test",
+        "status": "dispatched",
+        "parameters": [{"x": i} for i in range(n_points)],
+    }
+
+
+def mock_requests(get_side_effect=None):
+    """Build a mock `requests` module with a controllable get()."""
+    m = MagicMock()
+    if get_side_effect is not None:
+        m.get.side_effect = get_side_effect
+    return m
+
+
+class TestIsTaskCancelled(unittest.TestCase):
+    def test_local_mode_never_cancelled(self):
+        ex = make_executor()
+        with patch.object(task_executor, "requests", None):
+            self.assertFalse(ex._is_task_cancelled("t1"))
+
+    def test_network_error_returns_false(self):
+        ex = make_executor()
+        m = mock_requests()
+        m.get.side_effect = Exception("boom")
+        with patch.object(task_executor, "requests", m):
+            self.assertFalse(ex._is_task_cancelled("t1"))
+
+    def test_cancelled_status_returns_true(self):
+        ex = make_executor()
+        m = mock_requests()
+        m.get.return_value = FakeResponse(200, {"status": "cancelled"})
+        with patch.object(task_executor, "requests", m):
+            self.assertTrue(ex._is_task_cancelled("t1"))
+
+    def test_running_status_returns_false(self):
+        ex = make_executor()
+        m = mock_requests()
+        m.get.return_value = FakeResponse(200, {"status": "running"})
+        with patch.object(task_executor, "requests", m):
+            self.assertFalse(ex._is_task_cancelled("t1"))
+
+
+class TestExecuteTaskCancellation(unittest.TestCase):
+    def test_aborts_mid_run_and_reports_cancelled(self):
+        """Cancel after the first point: only 1 point runs, status=cancelled."""
+        ex = make_executor()
+        task = make_task(n_points=5)
+        run_params = []
+
+        orig_run = ex._run_simulation_point
+
+        def spy_run(params, index):
+            run_params.append(index)
+            return orig_run(params, index)
+
+        ex._run_simulation_point = spy_run
+
+        # GET sequence: (1) _hydrate_task downloads the task payload, (2)
+        # status check before point 0 -> running, (3) check before point 1 ->
+        # cancelled -> exactly one point executes.
+        m = mock_requests()
+        m.get.side_effect = [
+            FakeResponse(200, {}),  # hydrate: no payload, keep local params
+            FakeResponse(200, {"status": "running"}),
+            FakeResponse(200, {"status": "cancelled"}),
+        ]
+
+        reported = {}
+
+        def fake_report_results(task_id, results, metrics, logs, duration, status):
+            reported["status"] = status
+            reported["n_results"] = len(results)
+            return True
+
+        with patch.object(task_executor, "requests", m), \
+             patch.object(ex, "report_results", side_effect=fake_report_results), \
+             patch.object(ex, "report_progress", return_value=True):
+            ex.execute_task(task)
+
+        self.assertEqual(run_params, [0], "only the first point should run")
+        self.assertEqual(reported["status"], "cancelled")
+        self.assertEqual(reported["n_results"], 1)
+        self.assertTrue(ex._task_cancelled)
+
+    def test_not_cancelled_runs_all_points(self):
+        ex = make_executor()
+        task = make_task(n_points=3)
+        reported = {}
+
+        def fake_report_results(task_id, results, metrics, logs, duration, status):
+            reported["status"] = status
+            reported["n_results"] = len(results)
+            return True
+
+        m = mock_requests()
+        m.get.return_value = FakeResponse(200, {"status": "running"})
+
+        with patch.object(task_executor, "requests", m), \
+             patch.object(ex, "report_results", side_effect=fake_report_results), \
+             patch.object(ex, "report_progress", return_value=True):
+            ex.execute_task(task)
+
+        self.assertEqual(reported["status"], "completed")
+        self.assertEqual(reported["n_results"], 3)
+        self.assertFalse(ex._task_cancelled)
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 23 - 7
web/frontend/src/views/PlanDetail.vue

@@ -1206,13 +1206,29 @@ const applyDefaultModel = async (defaultModel: string) => {
 
 const stopSimulation = async () => {
   try {
-    await ElMessageBox.confirm('确认停止当前仿真?', '停止仿真', { type: 'warning' })
-    stopping.value = true
-    // API call to stop would go here
-    isRunning.value = false
-    ElMessage.success('仿真已停止')
-  } catch { /* cancelled */ }
-  finally { stopping.value = false }
+    await ElMessageBox.confirm(
+      '确认停止当前仿真?已完成的点会保留,执行器将在当前点结束后中断。',
+      '停止仿真',
+      { type: 'warning' },
+    )
+  } catch {
+    return // user dismissed the confirm dialog
+  }
+  stopping.value = true
+  try {
+    const tid = activeTask.value?.task_id
+    if (tid) {
+      await api.post(`/tasks/${tid}/cancel`)
+    }
+    // Refresh from backend: cancel flips the task status, so the progress
+    // banner (driven by activeTask.total_points snapshot) disappears.
+    await loadActiveTask()
+    ElMessage.success('已发送停止指令,执行器将在当前点结束后中断')
+  } catch (e: any) {
+    ElMessage.error('停止失败: ' + (e.message || e))
+  } finally {
+    stopping.value = false
+  }
 }
 
 const onFileChange = (file: any) => {