Add cancellable job execution

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Lihatoo 2026-06-12 17:30:59 +08:00
parent 11dc85d7ad
commit 048d4b0c07
2 changed files with 292 additions and 59 deletions

View file

@ -451,6 +451,12 @@
border: 1px solid rgba(21, 32, 27, 0.08); border: 1px solid rgba(21, 32, 27, 0.08);
} }
button.danger {
background: linear-gradient(135deg, #c73528 0%, #8f1f18 100%);
color: #fff;
border: 1px solid rgba(143, 31, 24, 0.22);
}
.pill { .pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -2093,8 +2099,11 @@ A+B</textarea>
strand_sequence_design_placeholder: "a b ~c 或 N10A5", strand_sequence_design_placeholder: "a b ~c 或 N10A5",
run_btn: "开始分析", run_btn: "开始分析",
run_design_btn: "开始设计", run_design_btn: "开始设计",
run_waiting_btn: "等待结果返回...", run_waiting_btn: "终止任务",
submission_locked: "已有任务在等待结果返回,请等待完成;如需强制重新提交请刷新页面。", status_canceling: "正在终止任务...",
status_canceled: "任务已终止",
cancel_job_failed: "终止任务失败",
submission_locked: "已有任务在等待结果返回,请终止或等待。",
example_btn: "载入示例", example_btn: "载入示例",
history_title: "最近任务", history_title: "最近任务",
clear_history: "清空历史", clear_history: "清空历史",
@ -2401,8 +2410,11 @@ A+B</textarea>
strand_sequence_design_placeholder: "a b ~c or N10A5", strand_sequence_design_placeholder: "a b ~c or N10A5",
run_btn: "Run Analysis", run_btn: "Run Analysis",
run_design_btn: "Run Design", run_design_btn: "Run Design",
run_waiting_btn: "Waiting for result...", run_waiting_btn: "Terminate Job",
submission_locked: "A job is already waiting for results. Wait for it to finish, or refresh the page to submit again.", status_canceling: "Terminating job...",
status_canceled: "Job canceled",
cancel_job_failed: "Cancel job failed",
submission_locked: "A job is already waiting for results. Cancel or wait.",
example_btn: "Load Example", example_btn: "Load Example",
history_title: "Recent Jobs", history_title: "Recent Jobs",
clear_history: "Clear History", clear_history: "Clear History",
@ -2660,6 +2672,7 @@ A+B</textarea>
let lastResult = null; let lastResult = null;
let currentJobId = null; let currentJobId = null;
let activeSubmission = false; let activeSubmission = false;
let cancellationRequested = false;
let probabilitySvgCache = new Map(); let probabilitySvgCache = new Map();
let styledSvgCache = new Map(); let styledSvgCache = new Map();
let viewerScale = 1; let viewerScale = 1;
@ -2686,8 +2699,11 @@ A+B</textarea>
} }
function updateRunButtonState() { function updateRunButtonState() {
runBtn.disabled = activeSubmission; runBtn.disabled = activeSubmission && (!currentJobId || cancellationRequested);
runBtn.setAttribute("aria-busy", activeSubmission ? "true" : "false"); runBtn.setAttribute("aria-busy", activeSubmission ? "true" : "false");
runBtn.classList.toggle("primary", !activeSubmission);
runBtn.classList.toggle("secondary", false);
runBtn.classList.toggle("danger", activeSubmission);
runBtn.textContent = activeSubmission ? t("run_waiting_btn") : runButtonBaseLabel(); runBtn.textContent = activeSubmission ? t("run_waiting_btn") : runButtonBaseLabel();
} }
@ -2696,6 +2712,27 @@ A+B</textarea>
updateRunButtonState(); updateRunButtonState();
} }
async function cancelCurrentJob() {
if (!currentJobId || cancellationRequested) return;
cancellationRequested = true;
setStatus(formatStatus(t("status_canceling")));
updateRunButtonState();
try {
const { response, data } = await fetchJsonOrThrow(`/api/jobs/${encodeURIComponent(currentJobId)}/cancel`, {
method: "POST",
});
if (!response.ok || data.status !== "success") {
throw new Error(data?.error || `Cancel failed with HTTP ${response.status}`);
}
refreshHealth();
} catch (error) {
cancellationRequested = false;
setStatus(`${t("cancel_job_failed")}: ${error.message}`, true);
} finally {
updateRunButtonState();
}
}
function setQueueMetric(node, value) { function setQueueMetric(node, value) {
node.textContent = value === undefined || value === null ? "-" : String(value); node.textContent = value === undefined || value === null ? "-" : String(value);
} }
@ -5370,7 +5407,6 @@ A+B</textarea>
} }
async function waitForJob(jobId) { async function waitForJob(jobId) {
const startedAt = Date.now();
for (;;) { for (;;) {
const { response, data: job } = await fetchJsonOrThrow(`/api/jobs/${jobId}`); const { response, data: job } = await fetchJsonOrThrow(`/api/jobs/${jobId}`);
if (!response.ok) { if (!response.ok) {
@ -5380,6 +5416,11 @@ A+B</textarea>
setStatus(formatStatus(t("status_queued"))); setStatus(formatStatus(t("status_queued")));
} else if (job.status === "running") { } else if (job.status === "running") {
setStatus(formatStatus(t("status_polling"))); setStatus(formatStatus(t("status_polling")));
} else if (job.status === "cancel_requested") {
setStatus(formatStatus(t("status_canceling")));
} else if (job.status === "canceled") {
refreshHealth();
throw new Error(t("status_canceled"));
} else if (job.status === "success") { } else if (job.status === "success") {
refreshHealth(); refreshHealth();
if (job.result && typeof job.elapsed_seconds === "number") { if (job.result && typeof job.elapsed_seconds === "number") {
@ -5390,9 +5431,6 @@ A+B</textarea>
refreshHealth(); refreshHealth();
throw new Error(job.error?.message || "Job failed"); throw new Error(job.error?.message || "Job failed");
} }
if (Date.now() - startedAt > 10 * 60 * 1000) {
throw new Error(t("request_timeout"));
}
await new Promise((resolve) => setTimeout(resolve, 1200)); await new Promise((resolve) => setTimeout(resolve, 1200));
} }
} }
@ -5412,8 +5450,9 @@ A+B</textarea>
return; return;
} }
setSubmissionLocked(true);
currentJobId = null; currentJobId = null;
cancellationRequested = false;
setSubmissionLocked(true);
setStatus(formatStatus(t("status_running"))); setStatus(formatStatus(t("status_running")));
results.innerHTML = ""; results.innerHTML = "";
@ -5428,6 +5467,7 @@ A+B</textarea>
throw new Error(data?.error || `Analysis failed with HTTP ${response.status}`); throw new Error(data?.error || `Analysis failed with HTTP ${response.status}`);
} }
currentJobId = data.job_id; currentJobId = data.job_id;
updateRunButtonState();
setStatus(formatStatus(t("status_waiting_job"))); setStatus(formatStatus(t("status_waiting_job")));
const result = await waitForJob(data.job_id); const result = await waitForJob(data.job_id);
result.job_id = data.job_id; result.job_id = data.job_id;
@ -5438,11 +5478,19 @@ A+B</textarea>
renderHistory(); renderHistory();
setStatus(formatStatus(t("status_done"))); setStatus(formatStatus(t("status_done")));
} catch (error) { } catch (error) {
if (cancellationRequested) {
results.innerHTML = `<div class="result-card"><h3>${t("status_canceled")}</h3><pre>${String(error)}</pre></div>`;
setStatus(formatStatus(t("status_canceled")));
} else {
results.innerHTML = `<div class="result-card"><h3>${t("request_failed")}</h3><pre>${String(error)}</pre></div>`; results.innerHTML = `<div class="result-card"><h3>${t("request_failed")}</h3><pre>${String(error)}</pre></div>`;
setStatus(t("status_error"), true); setStatus(t("status_error"), true);
}
refreshHealth(); refreshHealth();
} finally { } finally {
setSubmissionLocked(false); setSubmissionLocked(false);
currentJobId = null;
cancellationRequested = false;
updateRunButtonState();
} }
} }
@ -5459,8 +5507,9 @@ A+B</textarea>
return; return;
} }
setSubmissionLocked(true);
currentJobId = null; currentJobId = null;
cancellationRequested = false;
setSubmissionLocked(true);
setStatus(formatStatus(t("status_running"))); setStatus(formatStatus(t("status_running")));
results.innerHTML = ""; results.innerHTML = "";
@ -5475,6 +5524,7 @@ A+B</textarea>
throw new Error(data?.error || `Analysis failed with HTTP ${response.status}`); throw new Error(data?.error || `Analysis failed with HTTP ${response.status}`);
} }
currentJobId = data.job_id; currentJobId = data.job_id;
updateRunButtonState();
setStatus(formatStatus(t("status_waiting_job"))); setStatus(formatStatus(t("status_waiting_job")));
const result = await waitForJob(data.job_id); const result = await waitForJob(data.job_id);
result.job_id = data.job_id; result.job_id = data.job_id;
@ -5485,11 +5535,19 @@ A+B</textarea>
renderHistory(); renderHistory();
setStatus(formatStatus(t("status_done"))); setStatus(formatStatus(t("status_done")));
} catch (error) { } catch (error) {
if (cancellationRequested) {
results.innerHTML = `<div class="result-card"><h3>${t("status_canceled")}</h3><pre>${String(error)}</pre></div>`;
setStatus(formatStatus(t("status_canceled")));
} else {
results.innerHTML = `<div class="result-card"><h3>${t("request_failed")}</h3><pre>${String(error)}</pre></div>`; results.innerHTML = `<div class="result-card"><h3>${t("request_failed")}</h3><pre>${String(error)}</pre></div>`;
setStatus(t("status_error"), true); setStatus(t("status_error"), true);
}
refreshHealth(); refreshHealth();
} finally { } finally {
setSubmissionLocked(false); setSubmissionLocked(false);
currentJobId = null;
cancellationRequested = false;
updateRunButtonState();
} }
} }
@ -5727,7 +5785,13 @@ A+B</textarea>
historyImportInput.value = ""; historyImportInput.value = "";
} }
}); });
runBtn.addEventListener("click", runAnalysis); runBtn.addEventListener("click", () => {
if (activeSubmission) {
cancelCurrentJob();
return;
}
runAnalysis();
});
document.getElementById("exampleBtn").addEventListener("click", loadExample); document.getElementById("exampleBtn").addEventListener("click", loadExample);
workflowSelect.addEventListener("change", () => { workflowSelect.addEventListener("change", () => {
syncWorkflow(); syncWorkflow();

View file

@ -1,6 +1,8 @@
import json import json
import mimetypes import mimetypes
import multiprocessing
import os import os
import queue
import re import re
import threading import threading
import time import time
@ -80,6 +82,9 @@ IUPAC_CODES = "ACGTUWSMKRYBDHVN"
IUPAC_CONSTRAINT_TOKEN = re.compile(rf"[{IUPAC_CODES}](?:\d+)?") IUPAC_CONSTRAINT_TOKEN = re.compile(rf"[{IUPAC_CODES}](?:\d+)?")
VALID_COMPUTE = {"pfunc", "pairs", "mfe", "sample", "subopt", "ensemble_size"} VALID_COMPUTE = {"pfunc", "pairs", "mfe", "sample", "subopt", "ensemble_size"}
CANCEL_REQUESTED_STATUS = "cancel_requested"
CANCELED_STATUS = "canceled"
TERMINAL_JOB_STATUSES = {"success", "error", CANCELED_STATUS}
JOB_STORE = {} JOB_STORE = {}
JOB_LOCK = threading.Lock() JOB_LOCK = threading.Lock()
JOB_TTL_SECONDS = int(os.environ.get("NP_JOB_TTL_SECONDS", "3600")) JOB_TTL_SECONDS = int(os.environ.get("NP_JOB_TTL_SECONDS", "3600"))
@ -1321,12 +1326,14 @@ def run_job_payload(payload):
if workflow == "design": if workflow == "design":
design_options = parse_design_options(payload) design_options = parse_design_options(payload)
design_job_options = DesignOptions( design_job_option_kwargs = {
f_stop=design_options["stop_condition"], "f_stop": design_options["stop_condition"],
seed=design_options["seed"], "seed": design_options["seed"],
wobble_mutations=design_options["wobble_mutations"], "wobble_mutations": design_options["wobble_mutations"],
max_time=design_options["max_time_seconds"], }
) if design_options["max_time_seconds"] > 0:
design_job_option_kwargs["max_time"] = design_options["max_time_seconds"]
design_job_options = DesignOptions(**design_job_option_kwargs)
design_domain_map, design_domains = build_design_domains(payload.get("design_domains") or []) design_domain_map, design_domains = build_design_domains(payload.get("design_domains") or [])
target_strand_map, design_strands = build_design_strands( target_strand_map, design_strands = build_design_strands(
payload.get("strands") or [], payload.get("strands") or [],
@ -1562,21 +1569,64 @@ def set_job_data(job):
JOB_STORE[job["job_id"]] = dict(job) JOB_STORE[job["job_id"]] = dict(job)
def get_job_data(job_id, include_payload=False):
if redis_enabled():
raw = redis_client().get(job_key(job_id))
if raw is None:
return None
job = json.loads(raw)
if not include_payload:
job.pop("payload", None)
return job
with JOB_LOCK:
prune_jobs()
job = JOB_STORE.get(job_id)
if job is None:
return None
output = dict(job)
if not include_payload:
output.pop("payload", None)
return output
def should_keep_cancel_state(current, updates):
current_status = current.get("status") if current else None
next_status = updates.get("status")
if current_status in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
return next_status not in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}
return False
def update_job_data(job_id, **updates): def update_job_data(job_id, **updates):
if redis_enabled(): if redis_enabled():
client = redis_client() client = redis_client()
current = get_job(job_id) key = job_key(job_id)
if current is None: while True:
current = {"job_id": job_id, "created_at": time.time()} pipe = client.pipeline()
try:
pipe.watch(key)
raw = pipe.get(key)
current = json.loads(raw) if raw is not None else {"job_id": job_id, "created_at": time.time()}
if should_keep_cancel_state(current, updates):
pipe.unwatch()
return current
current.update(updates) current.update(updates)
current["updated_at"] = time.time() current["updated_at"] = time.time()
client.setex(job_key(job_id), JOB_TTL_SECONDS, json.dumps(current, ensure_ascii=False)) pipe.multi()
pipe.setex(key, JOB_TTL_SECONDS, json.dumps(current, ensure_ascii=False))
pipe.execute()
return current return current
except redis.WatchError:
continue
finally:
pipe.reset()
with JOB_LOCK: with JOB_LOCK:
current = JOB_STORE.get(job_id) current = JOB_STORE.get(job_id)
if current is None: if current is None:
current = {"job_id": job_id, "created_at": time.time()} current = {"job_id": job_id, "created_at": time.time()}
if should_keep_cancel_state(current, updates):
return dict(current)
current.update(updates) current.update(updates)
current["updated_at"] = time.time() current["updated_at"] = time.time()
prune_jobs(current["updated_at"]) prune_jobs(current["updated_at"])
@ -1607,52 +1657,161 @@ def create_job(payload):
return job_id return job_id
def _job_process_entry(payload, result_queue):
apply_thread_limits()
try:
result_queue.put({"status": "success", "result": run_job_payload(payload)})
except BaseException as exc:
result_queue.put(
{
"status": "error",
"error": {
"message": str(exc),
"traceback": traceback.format_exc(),
},
}
)
def terminate_process(process):
if not process.is_alive():
return
if hasattr(process, "kill"):
process.kill()
else:
process.terminate()
process.join(timeout=2)
if process.is_alive():
process.terminate()
process.join(timeout=2)
def _run_job(job_id, payload): def _run_job(job_id, payload):
started_at = time.time() started_at = time.time()
current = get_job_data(job_id)
if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
elapsed = round(time.time() - started_at, 3)
update_job_data(job_id, status=CANCELED_STATUS, payload=None, elapsed_seconds=elapsed)
return
log_event(f"running job_id={job_id}") log_event(f"running job_id={job_id}")
update_job_data(job_id, status="running") update_job_data(job_id, status="running")
if redis_enabled(): if redis_enabled():
redis_client().sadd(JOB_RUNNING_KEY, job_id) redis_client().sadd(JOB_RUNNING_KEY, job_id)
ctx = multiprocessing.get_context("spawn")
result_queue = ctx.Queue(maxsize=1)
process = ctx.Process(target=_job_process_entry, args=(payload, result_queue), daemon=True)
message = None
try: try:
result = run_job_payload(payload) process.start()
elapsed = round(time.time() - started_at, 3) while process.is_alive():
update_job_data(job_id, status="success", result=result, payload=None, elapsed_seconds=elapsed) current = get_job_data(job_id)
log_event(f"success job_id={job_id} elapsed={elapsed}s") if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
except Exception as exc: terminate_process(process)
elapsed = round(time.time() - started_at, 3) elapsed = round(time.time() - started_at, 3)
update_job_data( update_job_data(
job_id, job_id,
status="error", status=CANCELED_STATUS,
error={ error={"message": "Job canceled by user."},
"message": str(exc), result=None,
"traceback": traceback.format_exc(),
},
payload=None, payload=None,
elapsed_seconds=elapsed, elapsed_seconds=elapsed,
) )
log_event(f"error job_id={job_id} elapsed={elapsed}s message={exc}") log_event(f"canceled job_id={job_id} elapsed={elapsed}s")
return
try:
message = result_queue.get_nowait()
break
except queue.Empty:
time.sleep(0.25)
process.join(timeout=2)
if message is None:
try:
message = result_queue.get_nowait()
except queue.Empty:
message = None
current = get_job_data(job_id)
if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
elapsed = round(time.time() - started_at, 3)
update_job_data(
job_id,
status=CANCELED_STATUS,
error={"message": "Job canceled by user."},
result=None,
payload=None,
elapsed_seconds=elapsed,
)
log_event(f"canceled job_id={job_id} elapsed={elapsed}s")
return
elapsed = round(time.time() - started_at, 3)
if message and message.get("status") == "success":
update_job_data(job_id, status="success", result=message["result"], payload=None, elapsed_seconds=elapsed)
log_event(f"success job_id={job_id} elapsed={elapsed}s")
elif message and message.get("status") == "error":
update_job_data(
job_id,
status="error",
error=message.get("error") or {"message": "Job failed."},
payload=None,
elapsed_seconds=elapsed,
)
log_event(f"error job_id={job_id} elapsed={elapsed}s message={message.get('error', {}).get('message')}")
else:
update_job_data(
job_id,
status="error",
error={"message": f"Job process exited with code {process.exitcode}."},
payload=None,
elapsed_seconds=elapsed,
)
log_event(f"error job_id={job_id} elapsed={elapsed}s exitcode={process.exitcode}")
finally: finally:
terminate_process(process)
result_queue.close()
result_queue.join_thread()
if redis_enabled(): if redis_enabled():
redis_client().srem(JOB_RUNNING_KEY, job_id) redis_client().srem(JOB_RUNNING_KEY, job_id)
def get_job(job_id): def cancel_job(job_id):
if redis_enabled(): current = get_job_data(job_id, include_payload=True)
raw = redis_client().get(job_key(job_id)) if current is None:
if raw is None:
return None return None
job = json.loads(raw) status = current.get("status")
job.pop("payload", None) if status in TERMINAL_JOB_STATUSES:
return job output = dict(current)
with JOB_LOCK:
prune_jobs()
job = JOB_STORE.get(job_id)
if job is None:
return None
output = dict(job)
output.pop("payload", None) output.pop("payload", None)
return output return output
if redis_enabled() and status == "queued":
redis_client().lrem(JOB_QUEUE_KEY, 0, job_id)
if status == "queued":
updated = update_job_data(
job_id,
status=CANCELED_STATUS,
error={"message": "Job canceled by user."},
result=None,
payload=None,
)
else:
updated = update_job_data(
job_id,
status=CANCEL_REQUESTED_STATUS,
error={"message": "Cancellation requested."},
)
updated.pop("payload", None)
return updated
def get_job(job_id):
return get_job_data(job_id, include_payload=False)
def prune_shares(now=None): def prune_shares(now=None):
if SHARE_MAX_COUNT < 1: if SHARE_MAX_COUNT < 1:
@ -1759,6 +1918,8 @@ def run_worker_loop():
if raw is None: if raw is None:
continue continue
job = json.loads(raw) job = json.loads(raw)
if job.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}:
continue
payload = job.get("payload") payload = job.get("payload")
if payload is None: if payload is None:
continue continue
@ -1975,6 +2136,14 @@ class AppHandler(BaseHTTPRequestHandler):
parsed = urlparse(self.path) parsed = urlparse(self.path)
if parsed.path != "/api/analyze": if parsed.path != "/api/analyze":
if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/cancel"):
job_id = parsed.path.split("/")[-2]
job = cancel_job(job_id)
if job is None:
self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND))
return
self._respond(*json_bytes({"status": "success", "job": job}))
return
if parsed.path == "/api/jobs": if parsed.path == "/api/jobs":
try: try:
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))