Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions frontend_multi_user/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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')
Expand Down Expand Up @@ -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():
Expand Down
33 changes: 28 additions & 5 deletions frontend_multi_user/templates/plan_iframe.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -542,20 +556,25 @@
</p>
</div>
<div class="plan-meta-actions">
<form method="POST" action="{{ url_for('plan_retry') }}" class="plan-action-form" id="retry-form" {% if not ((task.state and task.state.name == 'failed') or task.stop_requested) %}style="display:none"{% endif %}>
<form method="POST" class="plan-action-form" id="retry-resume-form" {% if not ((task.state and task.state.name == 'failed') or task.stop_requested) %}style="display:none"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="id" value="{{ task.id }}">
<div class="plan-action-group">
<select name="model_profile" class="plan-action-model-profile" title="Choose model profile for retry">
<select name="model_profile" class="plan-action-model-profile" title="Choose model profile">
<option value="baseline" {% if selected_model_profile == 'baseline' %}selected{% endif %}>baseline</option>
<option value="premium" {% if selected_model_profile == 'premium' %}selected{% endif %}>premium</option>
<option value="frontier" {% if selected_model_profile == 'frontier' %}selected{% endif %}>frontier</option>
<option value="custom" {% if selected_model_profile == 'custom' %}selected{% endif %}>custom</option>
</select>
<button type="submit" class="btn-plan-meta">Retry</button>
<button type="submit" formaction="{{ url_for('plan_retry') }}" class="btn-plan-meta" title="Discard all progress and start over from scratch">Retry</button>
<button type="submit" formaction="{{ url_for('plan_resume') }}" class="btn-plan-meta" id="resume-btn" title="Continue from where it left off, skipping completed steps" {% if not (task.state and task.state.name == 'failed') %}disabled{% endif %}>Resume</button>
{% if resume_error == "version_mismatch" %}
<span class="plan-resume-error" id="resume-error-banner">Resume unavailable — plan was created with a different PlanExe version. Use Retry.</span>
{% endif %}
</div>
</form>
<span class="btn-plan-meta btn-plan-meta-disabled" id="retry-disabled" {% if (task.state and task.state.name == 'failed') or task.stop_requested %}style="display:none"{% endif %}>Retry</span>
<span class="btn-plan-meta btn-plan-meta-disabled" id="retry-disabled" title="Discard all progress and start over from scratch" {% if (task.state and task.state.name == 'failed') or task.stop_requested %}style="display:none"{% endif %}>Retry</span>
<span class="btn-plan-meta btn-plan-meta-disabled" id="resume-disabled" title="Continue from where it left off, skipping completed steps" {% if (task.state and task.state.name == 'failed') or task.stop_requested %}style="display:none"{% endif %}>Resume</span>

<form method="POST" action="{{ url_for('plan_stop') }}" class="plan-action-form" id="stop-form" {% if not (task.state and (task.state.name == 'pending' or task.state.name == 'processing') and not task.stop_requested) %}style="display:none"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 10 additions & 3 deletions worker_plan_database/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down