diff --git a/frontend_multi_user/src/app.py b/frontend_multi_user/src/app.py index d0cc83ae2..bada28c0b 100644 --- a/frontend_multi_user/src/app.py +++ b/frontend_multi_user/src/app.py @@ -54,6 +54,7 @@ from worker_plan_api.planexe_dotenv import DotEnvKeyEnum, PlanExeDotEnv from worker_plan_api.planexe_config import PlanExeConfig from worker_plan_api.model_profile import ModelProfileEnum, normalize_model_profile +from worker_plan_api.pipeline_version import PIPELINE_VERSION from worker_plan_api.llm_class_filter import ( ENV_PLANEXE_LLM_CONFIG_WHITELISTED_CLASSES, is_llm_class_allowed, @@ -3268,6 +3269,7 @@ def plan(): preferred_plan_view_mode = self._get_plan_view_mode_preference() parameters = task.parameters if isinstance(task.parameters, dict) else {} selected_model_profile = normalize_model_profile(parameters.get("model_profile")).value + resume_error = request.args.get('resume_error', '') return render_template( "plan_iframe.html", run_id=run_id, @@ -3276,6 +3278,7 @@ def plan(): failure_trace=failure_trace, preferred_plan_view_mode=preferred_plan_view_mode, selected_model_profile=selected_model_profile, + resume_error=resume_error, ) @self.app.route('/plan/download/report') @@ -3386,6 +3389,68 @@ def plan_retry(): self.db.session.commit() return redirect(url_for('plan', id=run_id)) + @self.app.route('/plan/resume', methods=['POST']) + @login_required + def plan_resume(): + run_id = request.form.get('id', '').strip() + task = self.db.session.get(PlanItem, run_id) + if task is None: + abort(404) + if not current_user.is_admin and str(task.user_id) != str(current_user.id): + abort(403) + + if task.state != PlanState.failed: + return redirect(url_for('plan', id=run_id)) + + # Reject resume if the snapshot was created by a different pipeline version. + stored_params = task.parameters if isinstance(task.parameters, dict) else {} + stored_version = stored_params.get("pipeline_version") + if stored_version != PIPELINE_VERSION: + return redirect(url_for( + 'plan', id=run_id, + resume_error="version_mismatch", + )) + + raw_profile = request.form.get("model_profile") + selected_model_profile = normalize_model_profile(raw_profile).value + parameters = dict(task.parameters) if isinstance(task.parameters, dict) else {} + parameters["model_profile"] = selected_model_profile + parameters["trigger_source"] = "frontend resume" + parameters["resume"] = True + parameters["resume_count"] = parameters.get("resume_count", 0) + 1 + task.parameters = parameters + + # Reset state to pending but preserve all artifacts (key difference from retry). + task.state = PlanState.pending + task.progress_message = "Resume requested by user." + task.stop_requested = False + task.stop_requested_timestamp = None + task.last_seen_timestamp = datetime.now(UTC) + + # Archive old incremental billing entries so the new run starts fresh. + CreditHistory.query.filter_by( + source="usage_billing_progress", + external_id=str(task.id), + ).update({"source": "usage_billing_settled"}) + + self.db.session.commit() + + event_context = { + "plan_id": str(task.id), + "task_handle": str(task.id), + "resume_of_plan_id": str(task.id), + "model_profile": selected_model_profile, + "resume_count": parameters["resume_count"], + } + event = EventItem() + event.event_type = EventType.TASK_PENDING + event.message = "Resumed failed task via frontend" + event.context = event_context + self.db.session.add(event) + self.db.session.commit() + + return redirect(url_for('plan', id=run_id)) + @self.app.route('/plan/meta') @login_required def plan_meta(): diff --git a/frontend_multi_user/templates/plan_iframe.html b/frontend_multi_user/templates/plan_iframe.html index e67c32404..46ca04dcd 100644 --- a/frontend_multi_user/templates/plan_iframe.html +++ b/frontend_multi_user/templates/plan_iframe.html @@ -169,6 +169,20 @@ cursor: not-allowed; pointer-events: none; } + .btn-plan-meta:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .plan-resume-error { + display: inline-flex; + align-items: center; + padding: 5px 10px; + font-size: 0.8rem; + color: var(--color-danger, #c0392b); + background: var(--color-danger-bg, #fdf0ef); + border: 1px solid var(--color-danger-border, #e6b0aa); + border-radius: var(--radius); + } .plan-state { display: inline-flex; align-items: center; @@ -542,20 +556,25 @@

-
+
- - + + + {% if resume_error == "version_mismatch" %} + Resume unavailable — plan was created with a different PlanExe version. Use Retry. + {% endif %}
- Retry + Retry + Resume
@@ -1123,8 +1142,12 @@ } const canRetry = state === "failed" || !!meta.stop_requested; - setVisible("retry-form", canRetry); + const canResume = state === "failed"; + setVisible("retry-resume-form", canRetry); setVisible("retry-disabled", !canRetry); + setVisible("resume-disabled", !canRetry); + var resumeBtn = document.getElementById("resume-btn"); + if (resumeBtn) resumeBtn.disabled = !canResume; const canStop = (state === "pending" || state === "processing") && !meta.stop_requested; setVisible("stop-form", canStop); diff --git a/worker_plan_database/app.py b/worker_plan_database/app.py index 18e809e9e..85620ec81 100644 --- a/worker_plan_database/app.py +++ b/worker_plan_database/app.py @@ -1172,17 +1172,24 @@ def process_pending_tasks() -> bool: logger.error("Failed to read pipeline metadata for plan %s: %s", task_id, exc) if snapshot_version != PIPELINE_VERSION: - mismatch_msg = ( + mismatch_detail = ( f"Not resumable: the intermediary files were generated by a different version of PlanExe " f"(snapshot={snapshot_version}, current={PIPELINE_VERSION}). " f"Use plan_retry for a clean restart." ) - logger.error("Plan %s: %s", task_id, mismatch_msg) + logger.error("Plan %s: %s", task_id, mismatch_detail) + # progress_message column is varchar(128); use a short summary. + short_msg = f"Not resumable: version mismatch (v{snapshot_version} vs v{PIPELINE_VERSION}). Use Retry." with app.app_context(): plan = db.session.get(PlanItem, task_id) if plan is not None: plan.state = PlanState.failed - plan.progress_message = mismatch_msg + plan.progress_message = short_msg[:128] + # Clear pipeline_version so the frontend version + # check correctly rejects subsequent resume attempts. + params = dict(plan.parameters) if isinstance(plan.parameters, dict) else {} + params.pop("pipeline_version", None) + plan.parameters = params db.session.commit() return False